sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+41 -45
View File
@@ -5,7 +5,7 @@
* npm run bench # all tasks
* npm run bench -- --task=composite-mini-report
* npm run bench -- --server=http://127.0.0.1:9876
* BENCH_JUDGE=off npm run bench # skip LLM judge (axis D fixed at 1.0)
* BENCH_JUDGE=off npm run bench # skip LLM judge (axis D not graded, excluded from the total)
*
* Prereq: scripts/server.sh start (or `npm start`) running on the configured port.
*/
@@ -14,15 +14,10 @@ import * as path from 'path';
import { parse as parseYaml } from 'yaml';
import { startFixtureServer } from '../src/bench/fixture-server.js';
import { runOneTask } from '../src/bench/runner.js';
import {
computeTotal,
gradeChecklist,
gradeInstructionsProgrammatic,
gradeTools,
} from '../src/bench/grader.js';
import { gradeReasoning, loadJudgeConfigFromEnv } from '../src/bench/judge.js';
import { writeRunSummary } from '../src/bench/summary.js';
import type { BenchResult, BenchTask, RawJobResult } from '../src/bench/types.js';
import { exitCodeForResults, runAndGradeTask } from '../src/bench/grade-run.js';
import { loadJudgeConfigFromProvider } from '../src/bench/judge.js';
import { formatAxisCell, formatTotalCell, writeRunSummary } from '../src/bench/summary.js';
import type { BenchResult, BenchTask } from '../src/bench/types.js';
import { loadConfig } from '../src/config.js';
import { logger } from '../src/logger.js';
@@ -87,12 +82,7 @@ function copyWorkspaceArtifacts(rawWorkspace: string, dest: string): void {
async function main(): Promise<void> {
const args = parseArgs();
const config = loadConfig();
const fallbackWorker = config.provider.workers?.[0];
const judgeConfig = loadJudgeConfigFromEnv({
endpoint: fallbackWorker?.endpoint ?? config.provider.baseUrl ?? 'http://127.0.0.1:11434/v1',
model: fallbackWorker?.model ?? config.provider.model ?? '',
apiKey: config.provider.apiKey ?? undefined,
});
const judgeConfig = loadJudgeConfigFromProvider(config.provider);
const tasks = loadTasks(args.benchRoot, args.taskFilter);
if (tasks.length === 0) {
@@ -108,37 +98,25 @@ async function main(): Promise<void> {
const results: BenchResult[] = [];
try {
for (const task of tasks) {
const startedAt = new Date().toISOString();
logger.info(`[bench] === task: ${task.id} ===`);
let raw: RawJobResult;
try {
raw = await runOneTask(task, {
serverUrl: args.serverUrl,
benchRoot: args.benchRoot,
webPort: fixtureServer.port,
});
} catch (err) {
logger.error(`[bench] runner failed for ${task.id}: ${(err as Error).message}`);
continue;
}
const tools = gradeTools(task, raw);
const checklist = gradeChecklist(task, raw);
const instructions = gradeInstructionsProgrammatic(task, raw);
const reasoning = await gradeReasoning(task, raw, judgeConfig);
const finishedAt = new Date().toISOString();
const result: BenchResult = {
taskId: task.id,
taskTitle: task.title,
startedAt,
finishedAt,
raw,
axes: { tools, checklist, instructions, reasoning },
total: 0,
};
result.total = computeTotal(result.axes);
const taskResultDir = path.join(args.resultDir, task.id);
fs.mkdirSync(taskResultDir, { recursive: true });
copyWorkspaceArtifacts(raw.workspacePath, path.join(taskResultDir, 'workspace'));
const result = await runAndGradeTask(task, {
runTask: (t) =>
runOneTask(t, {
serverUrl: args.serverUrl,
benchRoot: args.benchRoot,
webPort: fixtureServer.port,
}),
judgeConfig,
});
if (result.outcome === 'failed') {
logger.error(`[bench] runner failed for ${task.id}: ${result.error}`);
} else if (result.raw) {
copyWorkspaceArtifacts(result.raw.workspacePath, path.join(taskResultDir, 'workspace'));
}
fs.writeFileSync(path.join(taskResultDir, 'result.json'), JSON.stringify(result, null, 2), 'utf-8');
results.push(result);
}
@@ -149,10 +127,28 @@ async function main(): Promise<void> {
const summaryPath = writeRunSummary(args.resultDir, results);
logger.info(`[bench] wrote ${summaryPath}`);
// Concise stdout summary so CI / humans see totals without opening the file.
// Axes that were not graded print their status, never a percentage, and the
// scored weight travels with the total so two runs are comparable on purpose.
for (const r of results) {
if (r.outcome === 'failed') {
// eslint-disable-next-line no-console
console.log(`${r.taskId}: FAILED (not scored) error=${r.error ?? 'unknown'}`);
continue;
}
const weight = r.scoring ? `${r.scoring.scoredWeight}/${r.scoring.maxWeight}` : '?';
// eslint-disable-next-line no-console
console.log(`${r.taskId}: total=${r.total}/100 status=${r.raw.status} tools=${(r.axes.tools.score * 100).toFixed(0)}% checklist=${(r.axes.checklist.score * 100).toFixed(0)}% instr=${(r.axes.instructions.score * 100).toFixed(0)}% reasoning=${(r.axes.reasoning.score * 100).toFixed(0)}%`);
console.log(
`${r.taskId}: total=${formatTotalCell(r)}/100 (scored weight ${weight}) status=${r.raw?.status ?? '-'} ` +
`tools=${formatAxisCell(r.axes?.tools)} checklist=${formatAxisCell(r.axes?.checklist)} ` +
`instr=${formatAxisCell(r.axes?.instructions)} reasoning=${formatAxisCell(r.axes?.reasoning)}`,
);
}
const failedCount = results.filter((r) => r.outcome === 'failed').length;
if (failedCount > 0) {
// eslint-disable-next-line no-console
console.log(`${failedCount} of ${results.length} task(s) failed to run and were not scored.`);
}
process.exitCode = exitCodeForResults(results);
}
main().catch((err) => {
+3
View File
@@ -39,6 +39,9 @@ npm run build:server
echo "[4/5] Building UI"
npm --prefix ui run build
# Mermaid bundle for the server-side renderer (#1025). Lives here as well as in
# the npm build:ui script because this path calls the UI build directly.
node scripts/copy-mermaid-vendor.mjs
if [ "$SKIP_PYTHON" -eq 0 ]; then
echo "[5/5] Pre-baking Python packages for the Bash sandbox"
+278
View File
@@ -0,0 +1,278 @@
// scripts/check-dark-text.mjs
//
// Counts `dark:` + `text-slate-<n>` class names and fails when there are more
// than the baseline records.
//
// WHY
// ---
// ui/src/index.css INVERTS the slate ramp for the dark theme: `--slate-200` is
// a pale grey in light and a dark near-ground tone (#2f2f37) in dark, `--slate-700` is
// #334155 in light and a pale grey in dark. So a bare `text-slate-700` is
// already the right ink in BOTH themes. Adding `dark:text-slate-200` on top
// flips it a second time and the text lands on the dark theme's background
// colour — measured at 1.17:1 to 1.97:1 on the five spots fixed in #1388.
//
// WHY "DON'T ADD MORE" AND NOT "NEVER"
// ------------------------------------
// The pattern is not always wrong. Where the surface stays light in the dark
// theme -- a literal `bg-white` card, for instance -- dark ink IS the correct
// choice, and PromptCoachPanel.tsx has exactly such a case. So the existing
// uses stay in the baseline and only new ones fail.
//
// WHAT THIS CHECK DOES NOT SEE
// ----------------------------
// - It does not measure contrast. An element's real background comes from
// its ancestors, and that cannot be decided from static text. A check that
// pretended to measure it would be worse than no check at all.
// - It therefore also stops correct code: on a surface that stays light in
// the dark theme (a literal `bg-white`), dark ink is right and this check
// still counts it. Baseline it deliberately, with a comment saying why.
// - The scan is plain text, like Tailwind's own class extractor, so a class
// name written inside a comment or a test fixture counts too. One such
// mention is in the baseline on purpose (CacheWorkerCards.tsx documents
// the trap). To name the pattern in prose without being counted, write
// `dark:text-slate-*` with a star instead of a number.
// - It only looks at `slate`. `dark:text-gray-*`, `dark:text-zinc-*` and
// every other ramp are invisible to it -- `gray` is inverted in
// ui/src/index.css too, so the same trap exists there and is not guarded.
// - Variant chains around `dark:` are counted (`dark:hover:text-slate-300`,
// `group-hover:dark:text-slate-300`), but a colour set from JS (a ternary
// building the class name from pieces) is not.
// - It counts occurrences per file, not per element. Moving a use from one
// file to another reads as one addition plus one removal, and the addition
// fails until the baseline is refreshed.
//
// Usage:
// node scripts/check-dark-text.mjs # verify against baseline
// node scripts/check-dark-text.mjs --update # rewrite the baseline
// node scripts/check-dark-text.mjs --ui <dir> --baseline <file>
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { dirname, resolve, relative, join, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
/** `--flag value` / `--flag=value`; returns undefined when absent. */
function argValue(flag) {
const argv = process.argv.slice(2);
const i = argv.indexOf(`--${flag}`);
if (i !== -1 && argv[i + 1]) return argv[i + 1];
const inline = argv.find((a) => a.startsWith(`--${flag}=`));
return inline ? inline.slice(flag.length + 3) : undefined;
}
// `--ui` / `--baseline` exist so the check can be exercised against a fixture
// tree (see scripts/check-dark-text.test.ts). Normal runs take neither.
const UI_DIR = resolve(ROOT, argValue('ui') ?? 'ui');
const CONFIG_PATH = resolve(UI_DIR, 'tailwind.config.js');
const BASELINE_PATH = argValue('baseline')
? resolve(ROOT, argValue('baseline'))
: resolve(__dirname, 'dark-text-baseline.json');
const UPDATE = process.argv.includes('--update');
// ---------------------------------------------------------------------------
// 1. What to look for
// ---------------------------------------------------------------------------
// A variant chain (`dark:`, `dark:hover:`, `md:dark:`, `group-hover:dark:`)
// with `dark` somewhere in it, ending in `text-slate-<number>`.
const VARIANT = String.raw`(?:[a-z][\w-]*:)`;
const CLASS_RE = new RegExp(
String.raw`(?<![\w./:-])(${VARIANT}*dark:${VARIANT}*text-slate-\d+)(?![\w./-])`,
'g',
);
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// 2. Which files does Tailwind scan?
// ---------------------------------------------------------------------------
let config;
try {
config = (await import(pathToFileURL(CONFIG_PATH).href)).default;
} catch (err) {
console.error(`dark-text: cannot load ${relative(ROOT, CONFIG_PATH)} — run \`npm --prefix ui install\``);
console.error(String(err?.message ?? err));
process.exit(1);
}
/** Turn a Tailwind content glob into { dir, test } we can walk. */
function compileGlob(pattern) {
const clean = pattern.replace(/^\.\//, '');
const parts = clean.split('/');
const firstMagic = parts.findIndex((p) => /[*?{[]/.test(p));
const staticPrefix = parts.slice(0, firstMagic === -1 ? parts.length : firstMagic).join('/');
const rx = globToRegExp(clean);
return { dir: resolve(UI_DIR, staticPrefix), test: (rel) => rx.test(rel) };
}
/** Minimal glob -> RegExp: supports `**` `*` `?` and `{a,b}`. */
function globToRegExp(glob) {
let out = '';
for (let i = 0; i < glob.length; i += 1) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
i += 1;
if (glob[i + 1] === '/') {
i += 1;
out += '(?:[^/]+/)*';
} else {
out += '.*';
}
} else {
out += '[^/]*';
}
} else if (c === '?') {
out += '[^/]';
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) {
out += escapeRe(c);
} else {
out += `(?:${glob.slice(i + 1, end).split(',').map(escapeRe).join('|')})`;
i = end;
}
} else {
out += escapeRe(c);
}
}
return new RegExp(`^${out}$`);
}
function walk(dir, out = []) {
if (!existsSync(dir)) return out;
if (!statSync(dir).isDirectory()) {
out.push(dir);
return out;
}
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.isFile()) out.push(full);
}
return out;
}
const contentPatterns = (Array.isArray(config.content) ? config.content : config.content?.files ?? []).filter(
(p) => typeof p === 'string',
);
if (contentPatterns.length === 0) {
console.error('dark-text: tailwind.config.js has no string `content` globs to scan.');
process.exit(1);
}
const files = new Set();
for (const pattern of contentPatterns) {
const { dir, test } = compileGlob(pattern);
for (const file of walk(dir)) {
const relToUi = relative(UI_DIR, file).split(sep).join('/');
if (test(relToUi)) files.add(file);
}
}
// ---------------------------------------------------------------------------
// 3. Scan
// ---------------------------------------------------------------------------
/** key -> { file, class, count, lines[] } */
const found = new Map();
for (const file of [...files].sort()) {
const relPath = relative(ROOT, file).split(sep).join('/');
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
for (const match of line.matchAll(CLASS_RE)) {
const cls = match[1];
const key = `${relPath} ${cls}`;
const hit = found.get(key) ?? { file: relPath, class: cls, count: 0, lines: [] };
hit.count += 1;
hit.lines.push(i + 1);
found.set(key, hit);
}
});
}
const entries = [...found.values()]
.sort((a, b) => a.file.localeCompare(b.file) || a.class.localeCompare(b.class))
.map(({ file, class: cls, count }) => ({ file, class: cls, count }));
const total = entries.reduce((n, e) => n + e.count, 0);
// ---------------------------------------------------------------------------
// 4. Compare with the baseline (or write it)
// ---------------------------------------------------------------------------
if (UPDATE) {
const payload = {
_comment: [
'Known `dark:` + text-slate-<n> class names (see scripts/check-dark-text.mjs).',
'ui/src/index.css inverts the slate ramp for the dark theme, so a bare',
'text-slate-<n> is already right in both themes and the dark: variant flips it',
'a second time onto the background. Some of these are still correct — on a',
'surface that stays light in the dark theme, dark ink is what you want.',
'This file exists so the check fails on NEW ones only. Do not add to it by',
'hand: fix the class, then run `npm run check:dark-text -- --update`.',
],
total,
files: entries.length === 0 ? 0 : new Set(entries.map((e) => e.file)).size,
classes: entries.length === 0 ? 0 : new Set(entries.map((e) => e.class)).size,
entries,
};
writeFileSync(BASELINE_PATH, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`dark-text: baseline written — ${total} uses in ${payload.files} files (${payload.classes} distinct classes).`);
process.exit(0);
}
if (!existsSync(BASELINE_PATH)) {
console.error(`dark-text: ${relative(ROOT, BASELINE_PATH)} is missing. Run \`npm run check:dark-text -- --update\`.`);
process.exit(1);
}
const baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
const baselineCounts = new Map((baseline.entries ?? []).map((e) => [`${e.file} ${e.class}`, e.count]));
const added = [];
for (const [key, hit] of found) {
const allowed = baselineCounts.get(key) ?? 0;
if (hit.count > allowed) added.push({ ...hit, allowed });
}
const removed = [];
for (const [key, count] of baselineCounts) {
const current = found.get(key)?.count ?? 0;
if (current < count) removed.push({ key, count, current });
}
if (added.length > 0) {
console.error('dark-text: new `dark:` text-slate class — the dark theme inverts this ramp, so this flips the ink twice.\n');
for (const hit of added) {
const where = hit.lines.map((n) => `${hit.file}:${n}`).join(', ');
const extra = hit.allowed > 0 ? ` (baseline allows ${hit.allowed}, found ${hit.count})` : '';
console.error(` ${hit.class}${extra}\n ${where}`);
}
console.error(`\n${added.length} new use(s). ui/src/index.css maps slate onto CSS variables and INVERTS the ramp for`);
console.error('the dark theme (--slate-200 is #e2e8f0 in light and #2f2f37 in dark). A bare text-slate-<n>');
console.error('is therefore already the right ink in both themes; the dark: variant flips it a second time');
console.error('and the text lands on the background. Drop the variant:');
console.error(' text-slate-700 dark:text-slate-200 -> text-slate-700');
console.error('\nIf the surface under this text stays LIGHT in the dark theme (a literal bg-white card),');
console.error('the dark ink is correct — pin the colour so it cannot flip, e.g. text-[#1e293b], or');
console.error('record it with `npm run check:dark-text -- --update` and say in a comment why it belongs.');
console.error('\nIf the surface is a hard-coded dark colour in both themes (bg-[#0b1020]), pin the ink to a');
console.error('literal light value instead of any slate class, e.g. text-[#f1f5f9].');
console.error('\nThis check does not measure contrast — see the header of scripts/check-dark-text.mjs.');
process.exit(1);
}
if (removed.length > 0) {
console.log(`dark-text: ${removed.length} baselined use(s) are gone — thanks. Refresh with:`);
console.log(' npm run check:dark-text -- --update');
for (const r of removed) console.log(` ${r.key} (baseline ${r.count}, now ${r.current})`);
}
console.log(`dark-text: OK — ${total} known use(s), no new ones.`);
+141
View File
@@ -0,0 +1,141 @@
// scripts/check-dark-text.mjs が本当に効くことを守るテスト (#1388)。
//
// 守る対象は「いま何件あるか」ではなく、**暗い外観で二度反転する書き方を新しく
// 足したら赤くなる経路が残っていること**。控え(baseline)方式は放っておくと
// 「全部控えに入れて常に緑」になりやすいので、増えたら落ちる・減っても落ちない、
// の両方を固定する。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const script = join(repoRoot, 'scripts', 'check-dark-text.mjs');
// 見張りは content の並びから走査対象を決めるので、fixture にも同じ形の設定を置く。
const FIXTURE_CONFIG = `export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: ['selector', '[data-theme="dark"]'],
theme: { extend: { colors: { slate: { 200: 'var(--slate-200)' } } } },
};
`;
// テスト自身のソースに丸ごとの class 名を書くと、このリポジトリ本体の控えに
// 数えられてしまう。組み立てて使う。
const DARK = 'dark:';
const T = (n: number, ...variants: string[]) => `${DARK}${variants.join('')}text-slate-${n}`;
let dir: string;
const ui = () => join(dir, 'ui');
const baseline = () => join(dir, 'baseline.json');
function write(rel: string, body: string) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, body);
}
function run(...extra: string[]) {
return spawnSync(process.execPath, [script, '--ui', ui(), '--baseline', baseline(), ...extra], {
cwd: repoRoot,
encoding: 'utf8',
});
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'dark-text-'));
write('ui/package.json', '{ "type": "module" }\n');
write('ui/tailwind.config.js', FIXTURE_CONFIG);
// index.html も content に載っているので、こちらの 1 件も拾われる。
write('ui/index.html', `<div class="text-slate-700 ${T(200)}"></div>\n`);
write(
'ui/src/Panel.tsx',
[
'export const Panel = () => (',
` <div className="text-slate-700 ${T(300, 'hover:')} dark:bg-slate-800 dark:text-emerald-300">`,
` <span className="text-slate-600 group-hover:${T(400)} text-slate-900" />`,
' </div>',
');',
].join('\n'),
);
// .ts も content に載っている(ui/src/lib/workspaceDirs.ts がこの形)。
write('ui/src/roles.ts', `export const badge = 'bg-slate-100 text-slate-600 ${T(300)}';\n`);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('check-dark-text', () => {
it('dark: 付きの text-slate だけを拾う(素の指定・別の色・別の性質は拾わない)', () => {
expect(run('--update').status).toBe(0);
const found = JSON.parse(readFileSync(baseline(), 'utf8')) as {
total: number;
files: number;
entries: { file: string; class: string; count: number }[];
};
const classes = found.entries.map((e) => e.class).sort();
// 変化形は前にも後ろにも付いたまま数える。素の text-slate-*、dark:bg-slate-*、
// dark:text-emerald-* は 1 件も混じらない。
expect(classes).toEqual(
[T(200), `group-hover:${T(400)}`, T(300, 'hover:'), T(300)].sort(),
);
expect(found.total).toBe(4);
expect(found.entries.some((e) => e.file.endsWith('index.html'))).toBe(true);
expect(found.entries.some((e) => e.file.endsWith('roles.ts'))).toBe(true);
});
it('控えと同じなら通る', () => {
expect(run('--update').status).toBe(0);
const check = run();
expect(check.status).toBe(0);
expect(check.stdout).toContain('no new ones');
});
it('新しいファイルに 1 つ足すと落ちる', () => {
expect(run('--update').status).toBe(0);
write('ui/src/New.tsx', `export const New = () => <div className="text-slate-700 ${T(200)}" />;\n`);
const check = run();
expect(check.status).toBe(1);
expect(check.stderr).toContain(T(200));
expect(check.stderr).toContain('New.tsx');
// 落ちたときのメッセージが、なぜ駄目かと直し方を持っていること。
expect(check.stderr).toContain('INVERTS');
expect(check.stderr).toContain('-> text-slate-700');
});
it('同じファイルで件数が増えても落ちる', () => {
expect(run('--update').status).toBe(0);
write('ui/index.html', `<div class="${T(200)}"></div>\n<div class="${T(200)}"></div>\n`);
const check = run();
expect(check.status).toBe(1);
expect(check.stderr).toContain('baseline allows 1, found 2');
});
it('控えにある分を直したら、通したうえで控えの更新を促す', () => {
expect(run('--update').status).toBe(0);
write('ui/src/roles.ts', "export const badge = 'bg-slate-100 text-slate-600';\n");
const check = run();
expect(check.status).toBe(0);
expect(check.stdout).toContain('-- --update');
});
it('控えが無ければ落ちる(黙って通さない)', () => {
const check = run();
expect(check.status).toBe(1);
expect(check.stderr).toContain('--update');
});
it('このリポジトリ自身も控えと一致している', () => {
if (!existsSync(join(repoRoot, 'ui', 'node_modules'))) return; // 依存未導入なら見送る
const check = spawnSync(process.execPath, [script], { cwd: repoRoot, encoding: 'utf8' });
expect(check.stderr + check.stdout).toContain('OK');
expect(check.status).toBe(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { unlink, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const ui = join(root, 'ui');
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
process.chdir(ui);
const require = createRequire(join(ui, 'package.json'));
const { createServer: createViteServer } = await import(pathToFileURL(require.resolve('vite')).href);
const { chromium } = require('playwright');
const fixture = `/tmp/execution-conversations-${process.pid}.js`;
let vite, server, browser;
try {
await writeFile(fixture, `
import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '${ui}/src/index.css';
import i18n from '${ui}/src/i18n/index.ts';
import { TaskExecutionsProvider } from '${ui}/src/hooks/useTaskExecutions.ts';
import { ExecutionsTab } from '${ui}/src/components/detail/tabs/ExecutionsTab.tsx';
import { useVisibleDetailTabs } from '${ui}/src/components/detail/detailTabs.ts';
import { SpaceChatRail } from '${ui}/src/components/spaces/SpaceChatRail.tsx';
function App() {
const [kind, setKind] = React.useState('delegate');
const tabs = useVisibleDetailTabs(1).filter(t => ['delegates','subtasks'].includes(t.id));
return React.createElement('div', {style:{display:'flex',width:'100%',minWidth:0}},
React.createElement('main', {style:{flex:1,minWidth:0,padding:12}}, React.createElement(ExecutionsTab,{key:kind,taskId:1,kind})),
React.createElement(SpaceChatRail,{items:tabs,activeId:kind==='delegate'?'delegates':'subtasks',paneOpen:true,onSelect:id=>setKind(id==='delegates'?'delegate':'subtask'),ariaLabel:'Executions',renderLabel:t=>i18n.t(t.labelKey,{ns:'detail'}),appearClass:()=>''}));
}
window.setLanguage = async lang => {await i18n.changeLanguage(lang); render();};
const tree = createRoot(document.getElementById('root'));
const client = new QueryClient({defaultOptions:{queries:{retry:false}}});
function render(){ tree.render(React.createElement(QueryClientProvider,{client},React.createElement(TaskExecutionsProvider,{taskId:1},React.createElement(App)))); }
render();
`);
vite = await createViteServer({ root: ui, appType: 'custom', logLevel: 'error', server: { middlewareMode: true, hmr: false, fs: { allow: [root, '/tmp'] } } });
const executions = [
...Array.from({ length: 80 }, (_, i) => ({ id: `delegate:j:d${i}`, kind: 'delegate', jobId: 'j', rootJobId: 'j', title: `Research ${i + 1}`, status: 'success', createdAt: '2026-09-08T00:00:00Z' })),
{ id: 'subtask:k', kind: 'subtask', jobId: 'k', rootJobId: 'j', title: 'Implementation', status: 'succeeded', createdAt: '2026-09-08T00:01:00Z' },
];
const conversationRequests = [];
server = createServer(async (req, res) => {
if (req.url.startsWith('/api/')) {
let payload = { available: false, active: false };
if (req.url.endsWith('/executions')) payload = { executions, truncated: false };
if (req.url.endsWith('/conversation')) {
conversationRequests.push(req.url);
payload = { source: 'history', truncated: false, messages: [
{ role: 'user', content: 'Inspect the example files.' },
{ role: 'assistant', content: '**Readable result**', thinking: 'Recorded thinking details', llm: { durationMs: 1800, promptTokens: 400, completionTokens: 60 }, toolCalls: [{ id: 'call1', name: 'Read', arguments: '{"path":"sample.txt"}' }] },
{ role: 'tool', toolCallId: 'call1', content: 'Recorded tool result', isError: true, durationMs: 1234, cacheHit: false },
] };
}
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(payload)); return;
}
if (req.url === '/') {
res.setHeader('content-type', 'text/html');
res.end(await vite.transformIndexHtml('/', `<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/@fs${fixture}"></script></body></html>`)); return;
}
vite.middlewares(req, res);
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 600, height: 700 } });
await page.goto(`http://127.0.0.1:${server.address().port}/`);
await page.getByText('Readable result', { exact: true }).waitFor();
assert.equal(conversationRequests.length, 1, 'only the selected conversation is fetched');
for (const lang of ['ja', 'en']) {
await page.evaluate(lang => window.setLanguage(lang), lang);
for (const width of [320, 600, 900]) {
await page.setViewportSize({ width, height: 700 });
await page.evaluate(() => document.fonts.ready);
for (const kind of ['delegates', 'subtasks']) {
await page.getByTestId(`space-chat-tab-${kind}`).click();
await page.getByText('Readable result', { exact: true }).waitFor();
const panel = page.getByTestId(kind === 'delegates' ? 'delegate-conversations' : 'subtask-conversations');
assert.equal(await panel.getByRole('tablist').getAttribute('aria-orientation'), 'vertical');
if (kind === 'delegates') {
const tab = page.getByRole('tab', { name: /^Research 1 / });
await tab.click(); await tab.focus(); await page.keyboard.press('End');
assert.equal(await page.getByRole('tab', { name: /^Research 80 / }).getAttribute('aria-selected'), 'true');
await page.getByRole('searchbox').fill('Research 79');
assert.equal(await panel.getByRole('tab').count(), 1);
await page.getByRole('tab', { name: /^Research 79 / }).click();
await page.getByRole('searchbox').fill('');
assert.equal(await page.getByRole('tab', { name: /^Research 79 / }).getAttribute('aria-selected'), 'true');
}
await page.getByText('Readable result', { exact: true }).waitFor();
const thinking = page.getByRole('button', { name: lang === 'ja' ? '思考' : 'Thinking', exact: true });
await thinking.click();
await page.getByText('Recorded thinking details', { exact: true }).waitFor();
assert.equal(await page.getByText('1.8s', { exact: true }).count(), 1);
assert.equal(await page.getByText('Recorded tool result', { exact: true }).count(), 0);
await page.getByRole('button', { name: /Read.*sample/ }).click();
await page.getByText('Recorded tool result', { exact: true }).waitFor();
await page.getByRole('button', { name: lang === 'ja' ? '実行一覧を隠す' : 'Hide execution list' }).click();
assert.equal(await panel.getByRole('tablist').count(), 0);
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${lang}/${width}/${kind}: page overflow`);
await page.getByRole('button', { name: lang === 'ja' ? '実行一覧を表示' : 'Show execution list' }).click();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${lang}/${width}/${kind}: list overflow`);
}
}
}
if (process.env.SCREENSHOT_PATH) await page.screenshot({ path: process.env.SCREENSHOT_PATH });
console.log('execution conversations: 12 language/width/kind cases, 80-run search, keyboard, thinking, tool failure and collapse passed');
} finally {
await browser?.close(); await vite?.close(); server?.close(); await unlink(fixture).catch(() => {});
}
+283
View File
@@ -0,0 +1,283 @@
// scripts/check-tailwind-opacity.mjs
//
// Guards against Tailwind opacity modifiers that silently produce nothing.
//
// ui/tailwind.config.js maps our custom colors straight onto CSS variables
// (`slate: { 900: 'var(--slate-900)' }`). A bare `var(...)` has no slot for an
// alpha channel, so when Tailwind is asked for `bg-slate-900/50` it cannot
// build the color and drops the whole declaration. No error, no warning: the
// class name stays in the DOM, typecheck passes, tests pass, and the element
// simply renders with no background. The only way to notice is to grep the
// built CSS.
//
// This check reads the config, works out which color names lack an alpha slot,
// and scans the sources Tailwind is configured to scan. Existing hits are
// recorded in a baseline file so the check only fails on *new* ones.
//
// The scan is plain text, like Tailwind's own class extractor: a class name
// written inside a comment counts too. Mention such a class as `bg-slate-900`
// plus `/50` rather than as one token if you need to name it in prose.
//
// Usage:
// node scripts/check-tailwind-opacity.mjs # verify against baseline
// node scripts/check-tailwind-opacity.mjs --update # rewrite the baseline
// node scripts/check-tailwind-opacity.mjs --ui <dir> --baseline <file>
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { dirname, resolve, relative, join, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
/** `--flag value` / `--flag=value`; returns undefined when absent. */
function argValue(flag) {
const argv = process.argv.slice(2);
const i = argv.indexOf(`--${flag}`);
if (i !== -1 && argv[i + 1]) return argv[i + 1];
const inline = argv.find((a) => a.startsWith(`--${flag}=`));
return inline ? inline.slice(flag.length + 3) : undefined;
}
// `--ui` / `--baseline` exist so the check can be exercised against a fixture
// tree (see scripts/check-tailwind-opacity.test.ts). Normal runs take neither.
const UI_DIR = resolve(ROOT, argValue('ui') ?? 'ui');
const CONFIG_PATH = resolve(UI_DIR, 'tailwind.config.js');
const BASELINE_PATH = argValue('baseline')
? resolve(ROOT, argValue('baseline'))
: resolve(__dirname, 'tailwind-opacity-baseline.json');
const UPDATE = process.argv.includes('--update');
// ---------------------------------------------------------------------------
// 1. Which colors have no place to put an opacity value?
// ---------------------------------------------------------------------------
/**
* A color value can carry an opacity modifier when it is a function
* (`({ opacityValue }) => ...`) or a string with an `<alpha-value>`
* placeholder. A plain string is fine too *as long as* Tailwind can parse it
* as a color — hex, rgb(), hsl(). A bare `var(--x)` cannot be parsed, so the
* utility is dropped.
*/
function lacksAlphaSlot(value) {
if (typeof value === 'function') return false;
if (typeof value !== 'string') return false;
if (value.includes('<alpha-value>')) return false;
return value.includes('var(');
}
/** Walk theme.extend.colors and return the class fragments that are unsafe. */
function collectUnsafeColorNames(colors, prefix = []) {
const names = [];
for (const [key, value] of Object.entries(colors ?? {})) {
const path = key === 'DEFAULT' ? prefix : [...prefix, key];
if (value && typeof value === 'object') {
names.push(...collectUnsafeColorNames(value, path));
} else if (lacksAlphaSlot(value)) {
names.push(path.join('-'));
}
}
return names;
}
let config;
try {
config = (await import(pathToFileURL(CONFIG_PATH).href)).default;
} catch (err) {
console.error(`tailwind-opacity: cannot load ${relative(ROOT, CONFIG_PATH)} — run \`npm --prefix ui install\``);
console.error(String(err?.message ?? err));
process.exit(1);
}
const unsafeNames = collectUnsafeColorNames(config?.theme?.extend?.colors);
if (unsafeNames.length === 0) {
// Either the config stopped using bare `var()` colors (the real fix) or the
// shape changed under us. Either way, say so rather than passing silently.
console.log('tailwind-opacity: no opacity-less colors in tailwind.config.js — nothing to guard.');
process.exit(0);
}
// Longest first so `accent-soft` wins over `accent`.
unsafeNames.sort((a, b) => b.length - a.length);
// A utility token ends with `-<color>/<alpha>`; `<alpha>` is a number or an
// arbitrary value. Variants (`dark:`, `hover:`, `group-hover:`) sit in front
// and are matched by the leading run of class characters.
const CLASS_RE = new RegExp(
String.raw`(?<![\w./-])((?:[a-z][\w.:-]*?)-(?:${unsafeNames.map(escapeRe).join('|')})/(?:\d+(?:\.\d+)?|\[[^\]\s]+\]))`,
'g',
);
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// 2. Which files does Tailwind scan?
// ---------------------------------------------------------------------------
/** Turn a Tailwind content glob into { dir, test } we can walk. */
function compileGlob(pattern) {
const clean = pattern.replace(/^\.\//, '');
const parts = clean.split('/');
const firstMagic = parts.findIndex((p) => /[*?{[]/.test(p));
const staticPrefix = parts.slice(0, firstMagic === -1 ? parts.length : firstMagic).join('/');
const rx = globToRegExp(clean);
return { dir: resolve(UI_DIR, staticPrefix), test: (rel) => rx.test(rel) };
}
/** Minimal glob -> RegExp: supports `**` `*` `?` and `{a,b}`. */
function globToRegExp(glob) {
let out = '';
for (let i = 0; i < glob.length; i += 1) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
i += 1;
if (glob[i + 1] === '/') {
i += 1;
out += '(?:[^/]+/)*';
} else {
out += '.*';
}
} else {
out += '[^/]*';
}
} else if (c === '?') {
out += '[^/]';
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) {
out += escapeRe(c);
} else {
out += `(?:${glob.slice(i + 1, end).split(',').map(escapeRe).join('|')})`;
i = end;
}
} else {
out += escapeRe(c);
}
}
return new RegExp(`^${out}$`);
}
function walk(dir, out = []) {
if (!existsSync(dir)) return out;
if (!statSync(dir).isDirectory()) {
out.push(dir);
return out;
}
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.isFile()) out.push(full);
}
return out;
}
const contentPatterns = (Array.isArray(config.content) ? config.content : config.content?.files ?? []).filter(
(p) => typeof p === 'string',
);
if (contentPatterns.length === 0) {
console.error('tailwind-opacity: tailwind.config.js has no string `content` globs to scan.');
process.exit(1);
}
const files = new Set();
for (const pattern of contentPatterns) {
const { dir, test } = compileGlob(pattern);
for (const file of walk(dir)) {
const relToUi = relative(UI_DIR, file).split(sep).join('/');
if (test(relToUi)) files.add(file);
}
}
// ---------------------------------------------------------------------------
// 3. Scan
// ---------------------------------------------------------------------------
/** key -> { file, class, count, lines[] } */
const found = new Map();
for (const file of [...files].sort()) {
const relPath = relative(ROOT, file).split(sep).join('/');
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
for (const match of line.matchAll(CLASS_RE)) {
const cls = match[1];
const key = `${relPath} ${cls}`;
const hit = found.get(key) ?? { file: relPath, class: cls, count: 0, lines: [] };
hit.count += 1;
hit.lines.push(i + 1);
found.set(key, hit);
}
});
}
const entries = [...found.values()]
.sort((a, b) => a.file.localeCompare(b.file) || a.class.localeCompare(b.class))
.map(({ file, class: cls, count }) => ({ file, class: cls, count }));
const total = entries.reduce((n, e) => n + e.count, 0);
// ---------------------------------------------------------------------------
// 4. Compare with the baseline (or write it)
// ---------------------------------------------------------------------------
if (UPDATE) {
const payload = {
_comment: [
'Known uses of a Tailwind opacity modifier on a color that has no alpha slot',
'(see scripts/check-tailwind-opacity.mjs). These produce no CSS at all.',
'This file exists so the check fails on NEW ones only. Do not add to it by',
'hand: fix the class, then run `npm run check:tailwind-opacity -- --update`.',
],
total,
files: entries.length === 0 ? 0 : new Set(entries.map((e) => e.file)).size,
classes: entries.length === 0 ? 0 : new Set(entries.map((e) => e.class)).size,
entries,
};
writeFileSync(BASELINE_PATH, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`tailwind-opacity: baseline written — ${total} uses in ${payload.files} files (${payload.classes} distinct classes).`);
process.exit(0);
}
if (!existsSync(BASELINE_PATH)) {
console.error(`tailwind-opacity: ${relative(ROOT, BASELINE_PATH)} is missing. Run \`npm run check:tailwind-opacity -- --update\`.`);
process.exit(1);
}
const baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
const baselineCounts = new Map((baseline.entries ?? []).map((e) => [`${e.file} ${e.class}`, e.count]));
const added = [];
for (const [key, hit] of found) {
const allowed = baselineCounts.get(key) ?? 0;
if (hit.count > allowed) added.push({ ...hit, allowed });
}
const removed = [];
for (const [key, count] of baselineCounts) {
const current = found.get(key)?.count ?? 0;
if (current < count) removed.push({ key, count, current });
}
if (added.length > 0) {
console.error('tailwind-opacity: opacity modifier on a color that cannot carry one — Tailwind drops these rules silently.\n');
for (const hit of added) {
const where = hit.lines.map((n) => `${hit.file}:${n}`).join(', ');
const extra = hit.allowed > 0 ? ` (baseline allows ${hit.allowed}, found ${hit.count})` : '';
console.error(` ${hit.class}${extra}\n ${where}`);
}
console.error(`\n${added.length} new use(s). These colors are CSS variables in ui/tailwind.config.js, so \`/50\` style`);
console.error('modifiers compile to nothing. Write the color with its alpha inline instead, e.g.');
console.error(' bg-slate-900/50 -> bg-[rgb(15_23_42_/_0.5)]');
console.error('Colors without an alpha slot: ' + [...unsafeNames].sort().join(', '));
process.exit(1);
}
if (removed.length > 0) {
console.log(`tailwind-opacity: ${removed.length} baselined use(s) are gone — thanks. Refresh with:`);
console.log(' npm run check:tailwind-opacity -- --update');
for (const r of removed) console.log(` ${r.key} (baseline ${r.count}, now ${r.current})`);
}
console.log(`tailwind-opacity: OK — ${total} known use(s), no new ones.`);
+151
View File
@@ -0,0 +1,151 @@
// scripts/check-tailwind-opacity.mjs が本当に効くことを守るテスト (#1377)。
//
// 守る対象は「いま何件あるか」ではなく、**効かない組み合わせを書いたら赤くなる
// 経路が残っていること**。控え(baseline)方式は放っておくと「全部控えに入れて
// 常に緑」になりやすいので、増えたら落ちる・減ったら知らせる、の両方を固定する。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const script = join(repoRoot, 'scripts', 'check-tailwind-opacity.mjs');
/** 不透明度の置き場がある色(黒・白・<alpha-value> 付き)と、無い色(var())。 */
const FIXTURE_CONFIG = `export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
accent: 'var(--brand-primary, #18181b)',
'accent-soft': 'var(--brand-primary-soft, #f4f4f5)',
themed: 'rgb(var(--themed) / <alpha-value>)',
plain: '#123456',
slate: { 900: 'var(--slate-900)' },
},
},
},
};
`;
let dir: string;
const ui = () => join(dir, 'ui');
const baseline = () => join(dir, 'baseline.json');
function write(rel: string, body: string) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, body);
}
function run(...extra: string[]) {
return spawnSync(process.execPath, [script, '--ui', ui(), '--baseline', baseline(), ...extra], {
cwd: repoRoot,
encoding: 'utf8',
});
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'tw-opacity-'));
// 見つけ先の tailwind.config.js を ESM として読ませる。
write('ui/package.json', '{ "type": "module" }\n');
write('ui/tailwind.config.js', FIXTURE_CONFIG);
// index.html も content に載っているので、こちらの 1 件も拾われる。
write('ui/index.html', '<div class="bg-plain/50 bg-accent/5"></div>\n');
write(
'ui/src/Panel.tsx',
[
'export const Panel = () => (',
' <div className="bg-slate-900/50 dark:bg-accent-soft/80 border-accent/30">',
' <span className="bg-black/40 bg-white/50 bg-themed/60 bg-plain/25 bg-[rgb(15_23_42_/_0.5)]" />',
' </div>',
');',
].join('\n'),
);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('check-tailwind-opacity', () => {
it('不透明度の置き場が無い色だけを拾う(黒・白・<alpha-value>・具体値は拾わない)', () => {
const written = run('--update');
expect(written.status).toBe(0);
const found = JSON.parse(readFileSync(baseline(), 'utf8')) as {
total: number;
entries: { file: string; class: string; count: number }[];
};
const classes = found.entries.map((e) => e.class).sort();
// 変化形(dark: 等)は付いたまま数える。安全な色(黒・白・<alpha-value>・
// 具体値・すでに直した arbitrary value)は 1 件も混じらない。
expect(classes).toEqual(
['bg-accent/5', 'dark:bg-accent-soft/80', 'bg-slate-900/50', 'border-accent/30'].sort(),
);
expect(found.total).toBe(4);
expect(found.entries.some((e) => e.file.endsWith('index.html'))).toBe(true);
});
it('控えと同じなら通る', () => {
expect(run('--update').status).toBe(0);
const check = run();
expect(check.status).toBe(0);
expect(check.stdout).toContain('no new ones');
});
it('効かない組み合わせを 1 つ足すと落ちる', () => {
expect(run('--update').status).toBe(0);
write('ui/src/New.tsx', 'export const New = () => <div className="bg-slate-900/25" />;\n');
const check = run();
expect(check.status).toBe(1);
expect(check.stderr).toContain('bg-slate-900/25');
expect(check.stderr).toContain('New.tsx');
});
it('同じファイルで件数が増えても落ちる', () => {
expect(run('--update').status).toBe(0);
write(
'ui/src/Panel.tsx',
[
'export const Panel = () => (',
' <div className="bg-slate-900/50 dark:bg-accent-soft/80 border-accent/30">',
' <span className="bg-slate-900/50" />',
' </div>',
');',
].join('\n'),
);
const check = run();
expect(check.status).toBe(1);
expect(check.stderr).toContain('baseline allows 1, found 2');
});
it('控えにある分を直したら、通したうえで控えの更新を促す', () => {
expect(run('--update').status).toBe(0);
write(
'ui/src/Panel.tsx',
'export const Panel = () => <div className="bg-[rgb(15_23_42_/_0.5)]" />;\n',
);
const check = run();
expect(check.status).toBe(0);
expect(check.stdout).toContain('-- --update');
});
it('控えが無ければ落ちる(黙って通さない)', () => {
const check = run();
expect(check.status).toBe(1);
expect(check.stderr).toContain('--update');
});
it('このリポジトリ自身も控えと一致している', () => {
if (!existsSync(join(repoRoot, 'ui', 'node_modules'))) return; // 依存未導入なら見送る
const check = spawnSync(process.execPath, [script], { cwd: repoRoot, encoding: 'utf8' });
expect(check.stderr + check.stdout).toContain('OK');
expect(check.status).toBe(0);
});
});
+136
View File
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer as createHttpServer } from 'node:http';
import { realpathSync } from 'node:fs';
import { unlink, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const uiRoot = join(repoRoot, 'ui');
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
process.chdir(uiRoot);
const requireFromUi = createRequire(join(uiRoot, 'package.json'));
const { createServer: createViteServer } = await import(pathToFileURL(requireFromUi.resolve('vite')).href);
const playwrightModule = await import(pathToFileURL(requireFromUi.resolve('playwright')).href);
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
const fixturePath = `/tmp/task-files-toolbar-${process.pid}.js`;
await writeFile(fixturePath, `
import React from 'react';
import { createRoot } from 'react-dom/client';
import '${uiRoot}/src/index.css';
import i18n from '${uiRoot}/src/i18n/index.ts';
import { FileBrowser } from '${uiRoot}/src/components/files/FileBrowser.tsx';
const entries = [
{ name: 'report.pdf', path: 'report.pdf', kind: 'file', size: 10, modifiedAt: '2026-08-01T00:00:00Z' },
{ name: 'data.csv', path: 'data.csv', kind: 'file', size: 10, modifiedAt: '2026-08-01T00:00:00Z' },
];
const management = {
canManage: true, writableSection: true, selected: new Set(),
toggleSelect() {}, toggleSelectAll() {}, upload() {}, remove() {}, download() {},
isUploading: false, isDeleting: false, isDownloading: false, message: null,
};
const root = createRoot(document.getElementById('root'));
window.__renderToolbar = (managed) => root.render(React.createElement(FileBrowser, {
section: 'output', currentPath: '', entries, pathSegments: [], taskId: 42,
onSectionChange(section) { window.__selectedSection = section; },
onNavigate() {}, onPreview() {}, onRefresh() {},
management: managed ? management : undefined,
}));
window.__setToolbarLanguage = (language) => i18n.changeLanguage(language);
window.__renderToolbar(true);
`);
const vite = await createViteServer({
root: uiRoot,
appType: 'custom',
logLevel: 'error',
server: {
middlewareMode: true,
hmr: false,
fs: { allow: [repoRoot, '/tmp', realpathSync(join(uiRoot, 'node_modules'))] },
},
});
const server = createHttpServer(async (req, res) => {
if (req.url === '/') {
const html = await vite.transformIndexHtml('/', `<!doctype html><html><head><meta charset="utf-8"></head><body><main id="case" style="width:720px"><div id="root"></div></main><script type="module" src="/@fs${fixturePath}"></script></body></html>`);
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(html);
return;
}
vite.middlewares(req, res);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture server did not bind TCP');
let browser;
try {
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`http://127.0.0.1:${address.port}/`);
await page.getByTestId('task-file-origin-toggle').waitFor();
for (const language of ['ja', 'en']) {
await page.evaluate(async lang => { await window.__setToolbarLanguage(lang); }, language);
for (const managed of [true, false]) {
await page.evaluate(value => window.__renderToolbar(value), managed);
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.evaluate(() => document.fonts.ready);
for (const width of [699, 700, 701, 720, 721, 740, 620, 461, 460, 400, 320]) {
await page.locator('#case').evaluate((element, value) => { element.style.width = `${value}px`; }, width);
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const layout = await page.evaluate(() => {
const rect = element => {
const value = element.getBoundingClientRect();
return { left: value.left, top: value.top, right: value.right, bottom: value.bottom, width: value.width, height: value.height };
};
const tabs = document.querySelector('[data-testid="task-file-sections"]');
const actions = document.querySelector('.file-toolbar-actions');
if (!tabs || !actions) throw new Error('Toolbar regions missing');
const tabRect = rect(tabs);
const actionRect = rect(actions);
const buttons = [...tabs.querySelectorAll('button')].map(button => {
const buttonRect = rect(button);
const center = { x: buttonRect.left + buttonRect.width / 2, y: buttonRect.top + buttonRect.height / 2 };
return {
id: button.dataset.testid,
rect: buttonRect,
hit: document.elementFromPoint(center.x, center.y)?.closest('[data-testid]')?.getAttribute('data-testid') ?? null,
inside: buttonRect.left >= tabRect.left && buttonRect.right <= tabRect.right,
};
});
return { tabs: tabRect, actions: actionRect, buttons };
});
const context = `${language}, managed=${managed}, width=${width}`;
if (width <= 720) {
assert.ok(layout.actions.top >= layout.tabs.bottom, `actions must occupy a separate row: ${context}`);
} else {
assert.ok(layout.actions.top < layout.tabs.bottom, `wide toolbar must remain one row: ${context}`);
}
for (const button of layout.buttons) {
assert.ok(button.inside, `${button.id} is clipped outside the section region: ${context}; ${JSON.stringify({ button: button.rect, tabs: layout.tabs })}`);
assert.equal(button.hit, button.id, `${button.id} is not the hit target: ${context}`);
await page.evaluate(() => { window.__selectedSection = null; });
await page.getByTestId(button.id).click();
assert.equal(await page.evaluate(() => window.__selectedSection), button.id.replace('task-file-section-', ''), `${button.id} click did not select: ${context}`);
}
const origin = page.getByTestId('task-file-origin-toggle');
await origin.click();
assert.equal(await origin.getAttribute('aria-pressed'), 'true', `origin toggle did not activate: ${context}`);
await origin.click();
assert.equal(await origin.getAttribute('aria-pressed'), 'false', `origin toggle did not reset: ${context}`);
}
}
}
console.log('task-files-toolbar: 44 width/language/permission cases passed');
} finally {
await browser?.close();
await vite.close();
server.close();
await unlink(fixturePath).catch(() => {});
}
+184
View File
@@ -0,0 +1,184 @@
// Local-only integration check: actual browser audio processing and AAO relay, synthetic ASR and translation servers.
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { createServer } from 'node:http';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { once } from 'node:events';
import assert from 'node:assert/strict';
const root=resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require=createRequire(root+'/package.json');
const express=require('express');
const Database=require('better-sqlite3');
const {WebSocketServer}=require('ws');
const {chromium}=require('playwright');
const {createTranscriptionApi}=await import(root+'/src/bridge/transcription-api.ts');
const {attachTranscriptionWs}=await import(root+'/src/bridge/transcription-ws.ts');
const upstream=new WebSocketServer({port:0}); await once(upstream,'listening');
let audioFrames=0, finals=0;
upstream.on('connection', ws=>{
let n=0;
ws.send('{"type":"ready"}');
ws.on('message',(data,binary)=>{
if(binary){audioFrames++;n++; if(n%3===0) ws.send(JSON.stringify({type:'transcript',text:`議題は AAO の文字起こし機能です。途中結果 ${n}`,is_final:false,segment_id:'session',segments:[]}));}
else {const m=JSON.parse(data.toString());if(m.type==='config')ws.send('{"type":"config_ack"}');if(m.type==='end'){finals++;ws.send(JSON.stringify({type:'transcript',text:'議題は AAO の文字起こし機能です。次回までに動作を確認します。',is_final:true,segment_id:'session',segments:[{id:'s1',start:0,end:2,speaker:'1',text:'議題は AAO の文字起こし機能です。',timestamp_source:'model'}]}));}}
});
});
const translatedRequests=[];
const translationApp=express();translationApp.use(express.json());
translationApp.post('/v1/chat/completions',(req,res)=>{
const content=req.body.messages?.[0]?.content;
assert.equal(req.body.model,'test-translategemma');
assert.equal(typeof content,'string');
assert(content.startsWith('<<<source>>>ja<<<target>>>en<<<text>>>'));
const original=content.split('<<<text>>>')[1]; translatedRequests.push(original);
setTimeout(()=>res.json({choices:[{finish_reason:'stop',message:{content:`Translated: ${original}`}}]}),100);
});
const translationServer=translationApp.listen(0,'127.0.0.1');await once(translationServer,'listening');
const translationEndpoint=`http://127.0.0.1:${translationServer.address().port}/v1`;
const tools={speechStreamingUrl:`ws://127.0.0.1:${upstream.address().port}/v1/audio/stream`,speechStreamingTimeout:10};
const configuredEndpoint=tools.speechStreamingUrl; tools.speechStreamingUrl='';
const db=new Database(':memory:');
const fixtureRoot=mkdtempSync(resolve(tmpdir(),'aao-chat-transcription-'));
const timestamp=new Date().toISOString();
const space={id:'browser-space',kind:'personal',title:'Browser Workspace',description:'',ownerId:'browser-test',visibility:'private',visibilityScopeOrgId:null,status:'open',brandColor:null,workspaceDir:fixtureRoot,createdAt:timestamp,updatedAt:timestamp,myRole:'owner'};
const tasks=[1,2].map(id=>({id,title:id===1?'会議 A':'会議 B',body:'Browser transcription check',pieceName:'chat',profile:'default',outputFormat:'text',askPolicy:'auto',priority:'normal',state:'succeeded',workspacePath:resolve(fixtureRoot,`task-${id}`),ownerId:'browser-test',ownerName:'Browser Test',visibility:'private',spaceId:space.id,createdAt:timestamp,updatedAt:timestamp,latestJob:null,subtasks:[],tags:[],allowedTools:[]}));
for(const task of tasks) mkdirSync(task.workspacePath,{recursive:true});
const provenance=[];
const repo={getLatestJobForIssue:async()=>null,getLocalTask:async id=>tasks.find(task=>task.id===id)??null,getSpace:()=>space,getSpaceMemberRole:()=> 'owner',userCanViewSpace:()=>true,recordProvenance:entry=>provenance.push(entry)};
const app=express();
app.use((req,res,next)=>{req.user={id:'browser-test',role:'admin',email:'[email protected]',name:'Browser Test'};next();});
app.use('/api/transcription',createTranscriptionApi({db,repo,authActive:true,getTools:()=>tools}));
app.use(express.json());
app.get('/api/auth/me',(req,res)=>res.json(req.user));
app.get('/api/setup/status',(_req,res)=>res.json({needsSetup:false,authActive:true}));
app.get('/api/config',(_req,res)=>res.json({config:{configVersion:2,tools,storage:{},llm:{workers:[]}},overriddenByEnv:{}}));
app.put('/api/config',(req,res)=>{Object.assign(tools,req.body.tools);res.json({ok:true});});
app.get('/api/local/spaces',(_req,res)=>res.json([space]));
app.get('/api/local/spaces/:id',(_req,res)=>res.json(space));
app.get('/api/local/spaces/:id/members',(_req,res)=>res.json([{userId:'browser-test',name:'Browser Test',role:'owner',isOwner:true}]));
app.get('/api/local/tasks',(_req,res)=>res.json({tasks}));
app.get('/api/local/tasks/:id', (req,res)=>res.json({task:tasks.find(task=>task.id===Number(req.params.id))}));
app.get('/api/local/tasks/:id/overview-metrics',(_req,res)=>res.json({metrics:{collecting:false,toolCalls:null,compactions:null,activeDurationMs:null,workerId:null,workerName:null,model:null,decodeTps:null,decodeTpsMeasuredAt:null,jobId:null,jobStatus:null}}));
app.get('/api/local/tasks/:id/comments',(_req,res)=>res.json({comments:[]}));
app.get('/api/local/tasks/:id/movement-history',(_req,res)=>res.json({events:[]}));
app.get('/api/local/tasks/:id/executions',(_req,res)=>res.json({executions:[],active:false}));
app.get('/api/local/tasks/:id/files',(_req,res)=>res.json({basePath:'',path:'',entries:[],truncated:false}));
app.get('/api/local/spaces/:id/files',(_req,res)=>res.json({basePath:'',path:'',entries:[],truncated:false}));
app.get('/api/local/tasks/:id/console/status',(_req,res)=>res.json({active:false}));
app.get('/api/local/browser/sessions/task-session/:id',(_req,res)=>res.json({available:false}));
app.get('/api/pieces',(_req,res)=>res.json({pieces:[]}));
app.get('/api/pieces/:name',(req,res)=>res.json({piece:{name:req.params.name,description:'',movements:[],max_movements:1,initial_movement:''},source:'builtin'}));
app.get('/api/users/me/pets',(_req,res)=>res.json({pets:[],settings:{enabled:false,activePetId:null,size:48,position:'bottom-right',sound:false,reducedMotion:true,toolSparkEnabled:false,workerPets:{}}}));
app.get('/api/notifications/capabilities',(_req,res)=>res.json({}));
app.use('/api',(_req,res)=>res.status(404).json({error:'Not included in the local browser fixture'}));
app.use('/ui',express.static(root+'/ui/dist'));
app.get('/ui/*',(_req,res)=>res.sendFile(root+'/ui/dist/index.html'));
const server=createServer(app);
const stop=attachTranscriptionWs(server,{authActive:true,authenticateUpgrade:async()=>({id:'browser-test',role:'admin'}),getTools:()=>tools});
server.listen(0,'127.0.0.1');await once(server,'listening');
const base=`http://127.0.0.1:${server.address().port}`;
let browser, page;
const errors=[];
try{
browser=await chromium.launch({headless:true,args:['--no-sandbox','--use-fake-device-for-media-stream','--use-fake-ui-for-media-stream','--autoplay-policy=no-user-gesture-required']});
const context=await browser.newContext({viewport:{width:1440,height:1000},permissions:['microphone']});
await context.addInitScript(()=>{
localStorage.setItem('maestro.lang','ja');
navigator.mediaDevices.getDisplayMedia=async()=>{
const ctx=new AudioContext(); const oscillator=ctx.createOscillator(); const output=ctx.createMediaStreamDestination();oscillator.connect(output);oscillator.start();
window.__desktopAudioContext=ctx; return output.stream;
};
});
page=await context.newPage(); page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/ui/?page=transcription');
await page.getByText(/文字起こしはチャット右側/).waitFor();
await page.goto(base+'/ui/?space=browser-space&chat=1&tab=transcription');
const dockA=page.locator('[data-transcription-task="1"]');
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
await page.getByRole('button',{name:'設定を開く',exact:true}).click();
await page.getByPlaceholder('ws://gpu-server:8000/v1/audio/stream').fill(configuredEndpoint);
await page.locator('#translation-server-url').fill(translationEndpoint);
await page.locator('#translation-model').fill('test-translategemma');
const savedConfig=page.waitForResponse(r=>r.url().endsWith('/api/config') && r.request().method()==='PUT');
await page.getByRole('button',{name:/保存して反映|Save and apply/i}).click();
assert.equal((await savedConfig).status(),200);
await page.getByRole('button',{name:/^(ワークスペース|Workspaces)$/}).first().click();
if(!await dockA.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
await page.getByLabel('原文言語').selectOption('ja');
await page.getByLabel('翻訳先言語').selectOption('en');
await page.getByRole('checkbox',{name:'逐次翻訳',exact:true}).check();
await page.getByRole('button',{name:'開始',exact:true}).click();
await page.getByText('文字起こし中',{exact:true}).waitFor();
await page.waitForFunction(()=>[...document.querySelectorAll('textarea')].some(x=>x.value.includes('途中結果')));
await page.getByText('Translated: 議題は AAO の文字起こし機能です。',{exact:true}).first().waitFor();
const before=audioFrames;
// Navigate away via actual App navigation, then return; capture stays mounted.
await page.screenshot({path:'/tmp/aao-transcription-live-check.png',fullPage:true});
await page.getByRole('button',{name:/^(ヘルプ|Help)$/}).first().click();
await page.waitForTimeout(800);
assert(audioFrames>before,'audio continues on another tab');
await page.getByRole('button',{name:'録音中のチャットを開く',exact:true}).click();
await dockA.getByText('文字起こし中',{exact:true}).waitFor();
// Change chat through the real workspace list: A must keep recording and B must not acquire audio.
const expand=page.getByTestId('space-chat-list-expand');
if(await expand.isVisible()) await expand.click();
await page.locator('[data-testid="space-chat-row"][data-task-id="2"]').getByText('会議 B',{exact:true}).click();
const dockB=page.locator('[data-transcription-task="2"]');
if(!await dockB.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
await dockB.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
assert(await dockB.getByRole('button',{name:'開始',exact:true}).isDisabled(),'chat B cannot start while A records');
const beforeB=audioFrames;await page.waitForTimeout(700);assert(audioFrames>beforeB,'A audio continues while B is visible');
const firstSave=page.waitForResponse(r=>r.url().endsWith('/api/transcription/recordings') && r.request().method()==='POST');
await page.getByRole('button',{name:'録音を停止して保存',exact:true}).click();
const firstStored=await (await firstSave).json();
assert.equal(firstStored.recording.taskId,1);
assert(firstStored.workspaceFiles && !firstStored.workspaceError,'original transcript is also written into A workspace');
assert.equal(finals,2); assert(audioFrames>0);
assert(await dockB.isVisible(),'global stop keeps chat B displayed');
await page.getByRole('button',{name:'録音を停止して保存',exact:true}).waitFor({state:'hidden'});
assert(await dockB.getByRole('button',{name:'開始',exact:true}).isEnabled(),'finishing hidden A releases the recording lock');
await page.locator('[data-testid="space-chat-row"][data-task-id="1"]').getByText('会議 A',{exact:true}).click();
if(!await dockA.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
assert.equal(await page.locator('.transcription-transcript article').count(),2);
await page.waitForFunction(()=>{
const pairs=[...document.querySelectorAll('.transcription-pair')];
return pairs.length===4 && pairs.every(p=>p.textContent.includes('翻訳済み'));
});
await page.getByText('未保存の変更があります。',{exact:true}).waitFor();
const savedTranslation=page.waitForResponse(r=>r.url().endsWith('/api/transcription/recordings') && r.request().method()==='POST');
await page.getByRole('button',{name:'保存 / 再試行',exact:true}).click();
const saveResult=await (await savedTranslation).json();const stored=saveResult.recording;
assert.equal(stored.taskId,1);assert(!saveResult.workspaceError);
const taskARecordings=await (await context.request.get(base+'/api/transcription/recordings?taskId=1')).json();
const taskBRecordings=await (await context.request.get(base+'/api/transcription/recordings?taskId=2')).json();
assert.equal(taskARecordings.recordings.length,1);assert.equal(taskBRecordings.recordings.length,0);
const fileJson=JSON.parse(readFileSync(resolve(tasks[0].workspacePath,saveResult.workspaceFiles.json),'utf8'));
assert.deepEqual(fileJson.entries,stored.entries);assert.equal(fileJson.taskId,1);
const fileText=readFileSync(resolve(tasks[0].workspacePath,saveResult.workspaceFiles.txt),'utf8');
assert(fileText.includes('Translated:') && fileText.includes('原文') && fileText.includes('改訂'));
assert.equal(readdirSync(tasks[1].workspacePath).length,0,'B workspace remains untouched');
assert(provenance.some(entry=>entry.taskId===1 && entry.relPath.endsWith('.json')));
assert(stored.entries.every(e=>e.blocks.every(b=>b.translation?.sourceRevision===b.revision && b.translation.sourceText===b.text)));
assert.equal(new Set(stored.entries.flatMap(e=>e.blocks.map(b=>b.id))).size,4);
await page.screenshot({path:'/tmp/aao-transcription-wide-check.png',fullPage:true});
await page.reload();
await page.locator('[data-transcription-task="1"] .transcription-recording button').first().click();
await page.locator('.transcription-transcript article').nth(1).waitFor();
assert.equal(await page.locator('.transcription-transcript article').count(),2);
assert.equal(await page.getByRole('checkbox',{name:'逐次翻訳',exact:true}).isChecked(),false);
assert.equal(await page.locator('.transcription-pair').count(),4);
assert.equal(await page.getByText('Translated: 次回までに動作を確認します。',{exact:true}).count(),2);
const downloaded=page.waitForEvent('download');await page.getByRole('button',{name:'TXT',exact:true}).click();await downloaded;
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:'/tmp/aao-transcription-narrow-check.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'no horizontal overflow');
assert(await page.getByTestId('space-chat-tab-transcription').isVisible(),'transcription tab remains reachable on mobile');
console.log(JSON.stringify({audioFrames,finals,entries:2,translatedRequests:translatedRequests.length,taskId:1,workspaceFiles:saveResult.workspaceFiles,errors,screenshots:['/tmp/aao-transcription-wide-check.png','/tmp/aao-transcription-narrow-check.png']}));
assert.equal(errors.length,0);
}catch(error){console.error(JSON.stringify({url:page?.url(),errors,body:await page?.locator('body').innerText().catch(()=> '')}));await page?.screenshot({path:'/tmp/aao-transcription-failure.png',fullPage:true}).catch(()=>{});throw error;}finally{await browser?.close();stop();server.closeAllConnections();server.close();upstream.clients.forEach(s=>s.terminate());upstream.close();translationServer.closeAllConnections();translationServer.close();db.close();rmSync(fixtureRoot,{recursive:true,force:true});}
+123
View File
@@ -0,0 +1,123 @@
// Local full-App browser check against built assets, with synthetic API data only.
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { chromium } from 'playwright';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const timestamp = new Date().toISOString();
const space = { id: 'browser-space', kind: 'personal', title: 'Browser Workspace', description: '', ownerId: 'browser-test', visibility: 'private', status: 'open', myRole: 'owner', createdAt: timestamp, updatedAt: timestamp };
const task = { id: 1, title: 'Inspector fixture', body: 'SECRET_BODY', pieceName: 'chat', profile: 'default', outputFormat: 'text', askPolicy: 'auto', priority: 'normal', state: 'succeeded', ownerId: 'browser-test', visibility: 'private', spaceId: space.id, createdAt: timestamp, updatedAt: timestamp, latestJob: null, subtasks: [], tags: [], allowedTools: [] };
const app = express();
app.get('/api/auth/me', (req, res) => res.json({ id: 'browser-test', name: 'Browser Test', email: '[email protected]', role: req.headers.cookie?.includes('nonadmin=1') ? 'user' : 'admin' }));
app.get('/api/setup/status', (_req, res) => res.json({ needsSetup: false, authActive: true }));
app.get('/api/config', (_req, res) => res.json({ config: { configVersion: 2, tools: {}, storage: {}, llm: { workers: [] } }, overriddenByEnv: {} }));
app.get('/api/local/spaces', (_req, res) => res.json([space]));
app.get('/api/local/spaces/:id', (_req, res) => res.json(space));
app.get('/api/local/spaces/:id/members', (_req, res) => res.json([{ userId: 'browser-test', name: 'Browser Test', role: 'owner', isOwner: true }]));
app.get('/api/local/tasks', (_req, res) => res.json({ tasks: [task] }));
app.get('/api/local/tasks/:id', (_req, res) => res.json({ task }));
app.get('/api/local/tasks/:id/overview-metrics', (_req, res) => res.json({ metrics: { collecting: false } }));
app.get('/api/local/tasks/:id/comments', (_req, res) => res.json({ comments: [] }));
app.get('/api/local/tasks/:id/movement-history', (_req, res) => res.json({ events: [] }));
app.get('/api/local/tasks/:id/executions', (_req, res) => res.json({ executions: [], active: false }));
app.get(['/api/local/tasks/:id/files', '/api/local/spaces/:id/files'], (_req, res) => res.json({ basePath: '', path: '', entries: [], truncated: false }));
app.get('/api/local/tasks/:id/console/status', (_req, res) => res.json({ active: false }));
app.get('/api/pieces', (_req, res) => res.json({ pieces: [] }));
app.get('/api/pieces/:name', (req, res) => res.json({ piece: { name: req.params.name, description: '', movements: [], max_movements: 1, initial_movement: '' }, source: 'builtin' }));
app.get('/api/transcription/config', (_req, res) => res.json({ configured: false, finalizeTimeoutSeconds: 180 }));
app.get('/api/transcription/recordings', (_req, res) => res.json({ recordings: [] }));
app.get('/api/users/me/pets', (_req, res) => res.json({ pets: [], settings: { enabled: false, activePetId: null, size: 48, position: 'bottom-right', workerPets: {} } }));
app.get('/api/notifications/capabilities', (_req, res) => res.json({}));
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not included in the local browser fixture' }));
app.use('/ui', express.static(resolve(root, 'ui/dist')));
app.get('/ui/*', (_req, res) => res.sendFile(resolve(root, 'ui/dist/index.html')));
const server = app.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
let browser, page;
const errors = [];
try {
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
await context.addInitScript(() => {
localStorage.setItem('maestro.lang', 'ja');
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Synthetic clipboard denial'); } } });
});
page = await context.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', error => errors.push(error.message));
await page.goto(`${base}/ui/?space=browser-space&chat=1&tab=transcription&secret=SECRET_QUERY`);
const input = page.locator('[data-ui-id="transcription.title"]');
await input.waitFor();
await input.fill('SECRET_INPUT');
await page.evaluate(() => history.replaceState(null, '', location.href + '&secret=SECRET_QUERY'));
const activate = async () => {
await page.getByTestId('user-account-menu-trigger').click();
await page.getByRole('button', { name: 'UI調査モード', exact: true }).click();
await page.getByRole('region', { name: 'UI調査', exact: true }).waitFor();
};
await activate();
await input.click();
const panel = page.getByRole('region', { name: 'UI調査', exact: true });
await panel.locator('textarea:not([readonly])').fill('入力欄を広くしてください');
const report = await panel.locator('textarea[readonly]').inputValue();
assert.match(report, /ui\/src\/components\/transcription\/TranscriptionPage\.tsx:\d+:\d+/);
assert.match(report, /transcription\.title/);
assert.match(report, /コンポーネント: TranscriptionPage/);
assert.match(report, /入力欄を広くしてください/);
assert(!/SECRET_INPUT|SECRET_QUERY|SECRET_BODY|browser-test|\/home\//.test(report), 'report contains metadata and explicit wish only');
await panel.getByRole('button', { name: '修正依頼をコピー' }).click();
await panel.getByRole('status').filter({ hasText: '手動でコピー' }).waitFor();
await input.focus();
await page.keyboard.press('Control+k');
assert.equal(await page.getByRole('dialog').count(), 0, 'command palette stays closed during inspection');
await page.screenshot({ path: '/tmp/aao-ui-inspector-wide.png', fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await panel.locator('textarea:not([readonly])').fill('狭い画面でも入力できる');
const bounds = await panel.boundingBox();
assert(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 390, 'inspector fits narrow viewport');
await page.screenshot({ path: '/tmp/aao-ui-inspector-narrow.png', fullPage: true });
await page.keyboard.press('Escape');
await panel.waitFor({ state: 'hidden' });
await input.click();
await input.press('Control+a');
await input.pressSequentially('restored');
assert.equal(await input.inputValue(), 'restored');
// Radix disables outside pointer events and traps focus. Keep this regression mandatory.
{
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('button', { name: '+ 新規', exact: true }).first().click();
await page.getByTestId('space-title-input').waitFor();
// The admin shortcut is reachable even when the modal covers the account menu.
await page.keyboard.press('Control+Alt+i');
await page.getByTestId('space-title-input').click();
const modalWish = page.locator('section[data-ui-inspector-control] textarea:not([readonly])');
await modalWish.click({ timeout: 5000 });
assert(await page.getByTestId('space-title-input').isVisible(), 'inspector panel must not dismiss the inspected Radix modal');
await modalWish.press('Control+a');
await modalWish.pressSequentially('modal input works');
assert.equal(await modalWish.inputValue(), 'modal input works');
assert(await modalWish.evaluate(element => document.activeElement === element), 'inspector retains keyboard focus outside the Radix modal');
await page.screenshot({ path: '/tmp/aao-ui-inspector-modal.png', fullPage: true });
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
}
await context.addCookies([{ name: 'nonadmin', value: '1', url: base }]);
await page.reload();
await page.getByTestId('user-account-menu-trigger').click();
assert.equal(await page.getByRole('button', { name: 'UI調査モード', exact: true }).count(), 0, 'non-admin has no inspector entry');
await page.keyboard.press('Control+Alt+i');
assert.equal(await page.locator('section[data-ui-inspector-control]').count(), 0, 'non-admin cannot activate the inspector shortcut');
assert.equal(errors.length, 0, errors.join('\n'));
console.log(JSON.stringify({ passed: true, checks: ['admin activation', 'source/component/id', 'private values excluded', 'clipboard fallback', 'keyboard shortcut blocked', '390px panel', 'Escape and normal editing', 'Radix modal preserved with real keyboard input', 'non-admin entry absent'], screenshots: ['/tmp/aao-ui-inspector-wide.png', '/tmp/aao-ui-inspector-narrow.png', '/tmp/aao-ui-inspector-modal.png'] }));
} catch (error) {
console.error(JSON.stringify({ url: page?.url(), errors, body: await page?.locator('body').innerText().catch(() => '') }));
await page?.screenshot({ path: '/tmp/aao-ui-inspector-failure.png', fullPage: true }).catch(() => {});
throw error;
} finally {
await browser?.close();
server.closeAllConnections();
server.close();
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Copy the Mermaid browser bundle into vendor/ so the server can render
* diagrams (#1025).
*
* The copy comes from the UI's own dependency — deliberately the SAME install
* the app renders Markdown with. Adding `mermaid` to the server's package.json
* instead would create two versions that drift apart, which is precisely the
* failure this tool exists to remove.
*
* vendor/ is gitignored and is already what the Docker image ships (noVNC lives
* there too), so the runtime stage picks this up with the COPY it already has.
* Runs at the end of `npm run build:ui`, when ui/node_modules is guaranteed.
*/
import { copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'ui', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js');
const pkg = join(root, 'ui', 'node_modules', 'mermaid', 'package.json');
const destDir = join(root, 'vendor', 'mermaid');
const dest = join(destDir, 'mermaid.min.js');
if (!existsSync(src)) {
console.error(`[copy-mermaid-vendor] ${src} not found — run npm --prefix ui ci first.`);
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
copyFileSync(src, dest);
const version = existsSync(pkg) ? JSON.parse(readFileSync(pkg, 'utf8')).version : 'unknown';
// The UMD bundle does not expose its own version, and package.json is not
// copied — leave a sidecar so the tool can report what it validated with.
writeFileSync(join(destDir, 'version.txt'), `${version}\n`);
console.log(`[copy-mermaid-vendor] mermaid ${version} -> vendor/mermaid/mermaid.min.js`);
+201
View File
@@ -0,0 +1,201 @@
{
"_comment": [
"Known `dark:` + text-slate-<n> class names (see scripts/check-dark-text.mjs).",
"ui/src/index.css inverts the slate ramp for the dark theme, so a bare",
"text-slate-<n> is already right in both themes and the dark: variant flips it",
"a second time onto the background. Some of these are still correct — on a",
"surface that stays light in the dark theme, dark ink is what you want.",
"This file exists so the check fails on NEW ones only. Do not add to it by",
"hand: fix the class, then run `npm run check:dark-text -- --update`."
],
"total": 60,
"files": 19,
"classes": 6,
"entries": [
{
"file": "ui/src/components/chat/AskUserCard.tsx",
"class": "dark:text-slate-100",
"count": 3
},
{
"file": "ui/src/components/chat/AskUserCard.tsx",
"class": "dark:text-slate-200",
"count": 1
},
{
"file": "ui/src/components/chat/AskUserCard.tsx",
"class": "dark:text-slate-400",
"count": 3
},
{
"file": "ui/src/components/chat/CaptchaNotice.tsx",
"class": "dark:text-slate-200",
"count": 2
},
{
"file": "ui/src/components/chat/CaptchaNotice.tsx",
"class": "dark:text-slate-300",
"count": 8
},
{
"file": "ui/src/components/chat/CaptchaNotice.tsx",
"class": "dark:text-slate-400",
"count": 3
},
{
"file": "ui/src/components/chat/ChatComposerPanel.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/chat/ChatMessage.tsx",
"class": "dark:text-slate-200",
"count": 1
},
{
"file": "ui/src/components/chat/RotatingTips.tsx",
"class": "dark:text-slate-400",
"count": 1
},
{
"file": "ui/src/components/chat/RotatingTips.tsx",
"class": "dark:text-slate-500",
"count": 1
},
{
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
"class": "dark:text-slate-400",
"count": 1
},
{
"file": "ui/src/components/chat/WaterContextGauge.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "dark:text-slate-200",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "dark:text-slate-300",
"count": 2
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "dark:text-slate-400",
"count": 3
},
{
"file": "ui/src/components/embed/EmbedBlock.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
"class": "dark:text-slate-200",
"count": 1
},
{
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/embed/GroundingSourcesCard.tsx",
"class": "dark:text-slate-900",
"count": 1
},
{
"file": "ui/src/components/files/FileHistoryModal.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/settings/A2aServerForm.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/settings/A2aServerForm.tsx",
"class": "dark:text-slate-200",
"count": 1
},
{
"file": "ui/src/components/settings/A2aServerForm.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/settings/ExecutionForm.tsx",
"class": "dark:text-slate-300",
"count": 1
},
{
"file": "ui/src/components/settings/ExecutionForm.tsx",
"class": "dark:text-slate-400",
"count": 1
},
{
"file": "ui/src/components/settings/ServerTlsForm.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/settings/ServerTlsForm.tsx",
"class": "dark:text-slate-200",
"count": 3
},
{
"file": "ui/src/components/spaces/AppRunner.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/spaces/AppRunner.tsx",
"class": "dark:text-slate-200",
"count": 1
},
{
"file": "ui/src/components/spaces/CrossSpaceCalendar.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/spaces/SpaceCalendar.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/usage/cache/CacheWorkerCards.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/usage/UsagePage.tsx",
"class": "dark:text-slate-100",
"count": 1
},
{
"file": "ui/src/components/usage/UsagePage.tsx",
"class": "dark:text-slate-200",
"count": 2
},
{
"file": "ui/src/components/usage/UsagePage.tsx",
"class": "dark:text-slate-300",
"count": 4
},
{
"file": "ui/src/components/usage/UsagePage.tsx",
"class": "dark:text-slate-400",
"count": 1
}
]
}
+19
View File
@@ -0,0 +1,19 @@
// e2e-run-dir.mjs の型宣言。
//
// この .mjs は playwright の設定 3 本から import されており、宣言が無いと
// UI の型検査 (`npm run typecheck:ui`) が implicit any で落ちる。実装を
// TypeScript に移すと playwright 設定からの読み込み経路が変わるため、
// 宣言だけを添える。
export const OWNERSHIP_FILE: string;
export function createOwnedRunDir(
cacheRoot: string,
prefix: string,
): { directory: string; token: string };
export function cleanupOwnedRunDir(args: {
cacheRoot: string;
directory?: string;
prefix?: string;
token?: string;
}): boolean;
+35
View File
@@ -0,0 +1,35 @@
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { basename, join, relative, resolve, sep } from 'node:path';
import { randomUUID } from 'node:crypto';
export const OWNERSHIP_FILE = '.maestro-e2e-owned';
export function createOwnedRunDir(cacheRoot, prefix) {
mkdirSync(cacheRoot, { recursive: true });
const directory = mkdtempSync(join(realpathSync(cacheRoot), prefix));
const token = randomUUID();
writeFileSync(join(directory, OWNERSHIP_FILE), token, { mode: 0o600 });
return { directory, token };
}
export function cleanupOwnedRunDir({ cacheRoot, directory, prefix, token }) {
if (!directory || !token || !prefix) return false;
let rootReal;
let directoryReal;
try {
rootReal = realpathSync(cacheRoot);
if (lstatSync(directory).isSymbolicLink()) return false;
directoryReal = realpathSync(directory);
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw error;
}
const rel = relative(rootReal, directoryReal);
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || resolve(rootReal, rel) !== directoryReal) return false;
if (!basename(directoryReal).startsWith(prefix)) return false;
let actualToken;
try { actualToken = readFileSync(join(directoryReal, OWNERSHIP_FILE), 'utf8'); } catch { return false; }
if (actualToken !== token) return false;
rmSync(directoryReal, { recursive: true, force: true });
return true;
}
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { cleanupOwnedRunDir, createOwnedRunDir } from './e2e-run-dir.mjs';
describe('E2E owned run directory cleanup', () => {
const fixtureParents: string[] = [];
const fixtureParent = (prefix: string) => {
const directory = mkdtempSync(join(tmpdir(), prefix));
fixtureParents.push(directory);
return directory;
};
afterEach(() => {
for (const directory of fixtureParents.splice(0)) {
// Test-only cleanup: every path was minted and retained by fixtureParent.
rmSync(directory, { recursive: true, force: true });
expect(existsSync(directory)).toBe(false);
}
});
it('deletes only an owned directory and is idempotent', () => {
const root = fixtureParent('e2e-cache-');
const owned = createOwnedRunDir(root, 'maestro-e2e-');
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: owned.directory, prefix: 'maestro-e2e-', token: owned.token })).toBe(true);
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: owned.directory, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
});
it('rejects root, external existing directories, bad tokens, and symlink traversal', () => {
const root = fixtureParent('e2e-cache-');
const external = fixtureParent('maestro-e2e-external-');
writeFileSync(join(external, 'keep'), 'keep');
const owned = createOwnedRunDir(root, 'maestro-e2e-');
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: root, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: external, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: owned.directory, prefix: 'maestro-e2e-', token: 'wrong' })).toBe(false);
const link = join(root, 'maestro-e2e-link');
symlinkSync(external, link, 'dir');
expect(cleanupOwnedRunDir({ cacheRoot: root, directory: link, prefix: 'maestro-e2e-', token: owned.token })).toBe(false);
mkdirSync(join(owned.directory, 'still-here'));
});
});
+58
View File
@@ -0,0 +1,58 @@
import { cpSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import lockfile from 'proper-lockfile';
import { cleanupOwnedRunDir } from './e2e-run-dir.mjs';
const root = resolve(import.meta.dirname, '..');
const runDir = process.env.E2E_RUN_TMP;
if (!runDir) throw new Error('E2E_RUN_TMP is required');
const lockDir = join(root, 'node_modules', '.cache', 'maestro-e2e-build.lock');
const cacheDir = join(root, 'node_modules', '.cache');
mkdirSync(cacheDir, { recursive: true });
const run = (command, args, env = process.env) => new Promise((resolveRun, reject) => {
const child = spawn(command, args, { cwd: root, env, stdio: 'inherit' });
child.once('error', reject);
child.once('exit', code => code === 0 ? resolveRun() : reject(new Error(`${command} exited ${code}`)));
});
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
let server;
try {
const releaseBuildLock = await lockfile.lock(cacheDir, {
lockfilePath: lockDir, stale: 120_000,
retries: { retries: 600, factor: 1, minTimeout: 100, maxTimeout: 100 },
});
try {
await run(npm, ['run', 'build:server']);
await run(npm, ['run', 'build:ui']);
cpSync(join(root, 'dist'), join(runDir, 'dist'), { recursive: true });
cpSync(join(root, 'ui', 'dist'), join(runDir, 'ui-dist'), { recursive: true });
mkdirSync(join(runDir, 'scripts'), { recursive: true });
cpSync(join(root, 'scripts', 'setup-lib.mjs'), join(runDir, 'scripts', 'setup-lib.mjs'));
} finally {
await releaseBuildLock();
}
server = spawn(process.execPath, [join(runDir, 'dist', 'main.js')], {
cwd: root,
env: { ...process.env, UI_DIST_PATH: join(runDir, 'ui-dist') },
stdio: 'inherit',
});
const stop = () => server?.kill('SIGTERM');
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
const code = await new Promise((resolveExit, reject) => {
server.once('error', reject);
server.once('exit', resolveExit);
});
process.exitCode = typeof code === 'number' ? code : 0;
} finally {
cleanupOwnedRunDir({
cacheRoot: process.env.E2E_CACHE_ROOT,
directory: runDir,
prefix: process.env.E2E_RUN_PREFIX,
token: process.env.E2E_RUN_TOKEN,
});
}
-3
View File
@@ -19,7 +19,6 @@
# Env override:
# GATEWAY_PORT=4000 # gateway.listen_port を上書き (config.yaml より優先)
# AAO_CONFIG=/etc/aao/config-gateway.yaml # 専用 config を使う場合
# AAO_GATEWAY_NO_DB=1 # ステートレス起動 (virtual_keys は config 経由のみ、budget/rate-limit 無効)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -36,7 +35,6 @@ usage() {
echo "Env:"
echo " GATEWAY_PORT=$GATEWAY_PORT"
echo " AAO_CONFIG=${AAO_CONFIG:-<repo config.yaml>}"
echo " AAO_GATEWAY_NO_DB=${AAO_GATEWAY_NO_DB:-<unset, DB enabled>}"
exit 1
}
@@ -73,7 +71,6 @@ do_start() {
AAO_MODE=gateway \
GATEWAY_PORT="$GATEWAY_PORT" \
${AAO_CONFIG:+AAO_CONFIG="$AAO_CONFIG"} \
${AAO_GATEWAY_NO_DB:+AAO_GATEWAY_NO_DB="$AAO_GATEWAY_NO_DB"} \
nohup node dist/main.js >> "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# noVNC の画面で触られたくないブラウザの機能を止める管理ポリシーを入れる (issue #1148)。
#
# 入れているのは 2 つ。
#
# AllowFileSelectionDialogs=false ファイル選択の画面を出させない(下の説明)
# IncognitoModeAvailability=1 シークレット窓を開かせない
#
# シークレット窓を止める理由。**遮断が効かないからではない。**
# 実測ではこうなる。launchPersistentContext で立ち上げると、人が Ctrl+Shift+N で
# 開いたシークレット窓は既定の入れ物のページとして Playwright に見え、
# 既定の入れ物に掛けた遮断がそのまま効く(127.0.0.1 への遷移が
# ERR_BLOCKED_BY_CLIENT で断たれることを確認済み)。つまり今の Playwright では、
# このポリシーが無くても穴は開いていない。
#
# それでも止めるのは保険。「シークレット窓が既定の入れ物のページとして見える」のは
# Playwright の内部の振る舞いで、版が変われば変わりうる。変わった瞬間に
# 遮断の掛からない窓が生まれるので、その手前で開けなくしておく。
# エージェント側の入れ物づくり(browser.newContext)はこのポリシーの影響を
# 受けない(実機で確認済み)。
#
# noVNC の画面(CAPTCHA を人が解く画面)は、ブラウザの中身がそのまま人に触れる。
# ファイルを選ぶボタンを押すと OS のファイル選択画面が開き、サーバー上のファイルを
# 選んで持ち出せてしまう。Chromium 系の管理ポリシー AllowFileSelectionDialogs を
# false にすると、押しても何も起きなくなる。
#
# エージェント側は壊れない。**エージェントがブラウザへファイルを送る経路が
# そもそも無い**(src/ に filechooser も setInputFiles も 1 か所も無い。
# ui/e2e/ にあるのは UI の E2E テストが画面を操作するためのもので、
# エージェントの経路ではない)。
# 将来ファイル送信を足すときは setInputFiles(内部は CDP の
# DOM.setFileInputFiles)を使うこと。あれは選択画面を通らないので
# このポリシーの影響を受けない。
#
# 置き場所はブラウザの銘柄ごとに違う。Playwright 同梱のものは
# 「Google Chrome for Testing」なので /etc/opt/chrome_for_testing/policies/managed。
# config.yaml の browser.channel で本家 Chrome / Chromium に差し替えられるので、
# 3 か所すべてに置く。
# msedge は未対応。Edge の置き場所は裏が取れていないので、推測で足さない
# (browser.channel: msedge を選ぶと、このポリシーは効かない)。
#
# 使い方: sudo scripts/install-browser-policy.sh
set -euo pipefail
SRC="$(cd "$(dirname "$0")/.." && pwd)/deploy/browser-policy/agent-orchestrator.json"
if [ ! -f "$SRC" ]; then
echo "ERROR: ポリシーファイルが見つかりません: $SRC" >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo > /dev/null 2>&1; then
SUDO="sudo"
else
echo "ERROR: root 権限が必要です(sudo も見つかりません)" >&2
exit 1
fi
fi
POLICY_DIRS=(
/etc/opt/chrome_for_testing/policies/managed
/etc/opt/chrome/policies/managed
/etc/chromium/policies/managed
)
for dir in "${POLICY_DIRS[@]}"; do
$SUDO mkdir -p "$dir"
$SUDO cp "$SRC" "$dir/agent-orchestrator.json"
$SUDO chmod 644 "$dir/agent-orchestrator.json"
echo " ✓ $dir/agent-orchestrator.json"
done
echo "ファイル選択の画面とシークレット窓を禁じるポリシーを入れました。"
echo "既に動いているブラウザには効きません。反映するには再起動してください(scripts/server.sh restart)。"
+13 -2
View File
@@ -47,10 +47,16 @@ if $PIP install --break-system-packages -r "$REQ"; then
elif $PIP install -r "$REQ"; then
:
else
soft_fail "pip install failed. If this is a permission error, retry as: sudo $PIP install --break-system-packages -r runtime/python-requirements.txt"
# 導入に失敗しても、ここでは抜けない。**何が足りないかを出してから**抜ける。
# 以前はここで即座に終わっていたので、ログに残るのが「入らなかった」だけで、
# どのパッケージが欠けたのか誰にも分からなかった。pip は 1 件失敗すると
# そこで全部やめるため、要求ファイルの後ろ側がまとめて落ちる(実例: 版を
# Python 3.11 以上のものに固定してしまい、3.10 の本番で 8 件が欠けた。
# issue #1088)。
INSTALL_FAILED=1
fi
echo "[prebake-python] done. Verifying imports..."
echo "[prebake-python] Verifying imports..."
if command -v python3 >/dev/null 2>&1; then
python3 - <<'PYEOF' || echo "[prebake-python] WARNING: some packages failed to import (see above)." >&2
import importlib, sys
@@ -76,3 +82,8 @@ if missing:
print(f"[prebake-python] all {len(mods)} packages import OK.")
PYEOF
fi
if [ "${INSTALL_FAILED:-0}" -eq 1 ]; then
soft_fail "pip install failed. If this is a permission error, retry as: sudo $PIP install --break-system-packages -r runtime/python-requirements.txt"
fi
+11 -7
View File
@@ -119,19 +119,23 @@ fi
echo ""
# -------------------------------------------------
# 5. LibreOffice(PowerPoint プレビューの画像化用・オプション)
# 5. LibreOffice(Officeプレビュー・Excel数式検証用、オプション)
# -------------------------------------------------
echo "[5/5] LibreOffice(オプション)..."
# ファイルプレビューで .pptx/.ppt をスライド画像化する際、soffice で PDF 化してから
# PyMuPDF で PNG にする(src/bridge/office-preview.ts)。未導入でも他機能には影響せず、
# PowerPoint プレビューだけが「変換エンジン未導入」のダウンロード案内に切り替わる。
# Excel(.xlsx/.xlsm) プレビューは exceljs(npm)で完結するので soffice 不要。
# Docker イメージでは Dockerfile が libreoffice-impress を同梱する。
# .pptx/.ppt の画像化にはsoffice、Excel詳細編集の数式再計算にはCalc + Python UNOを使う。
# 未導入でもExcelプレビューとセル単位の軽量編集は利用できる。Dockerイメージは
# libreoffice-impress / libreoffice-calc / python3-unoを同梱する。
if command -v soffice >/dev/null 2>&1; then
pass "soffice: $(command -v soffice)"
else
warn "soffice 未検出 — PowerPoint(.pptx/.ppt) プレビューの画像化に必要です。'sudo apt install --no-install-recommends libreoffice-impress' を実行してください(Excel プレビューには不要)"
warn "soffice 未検出 — PowerPointプレビューとExcel数式検証に必要です。'sudo apt install --no-install-recommends libreoffice-impress libreoffice-calc python3-uno' を実行してください(Excelプレビューには不要)"
fi
if command -v python3 >/dev/null 2>&1 && python3 -c 'import uno' >/dev/null 2>&1; then
pass "Python UNO: 利用可能"
else
warn "Python UNO 未検出 — Excel詳細編集の数式検証に必要です。'sudo apt install --no-install-recommends libreoffice-calc python3-uno' を実行してください"
fi
echo ""
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env node
/**
* Offline regression check for the server egress boundary artifacts.
*
* This check never applies host firewall/systemd policy and never contacts the
* public network. It validates the shipped templates, exercises an in-process
* exact-host+port proxy policy, and demonstrates the transports that still
* require the OS boundary because HTTP proxy environment variables do not
* contain them.
*/
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import http from 'node:http';
import net from 'node:net';
import dgram from 'node:dgram';
import { Resolver } from 'node:dns/promises';
import { execFile } from 'node:child_process';
const SYSTEMD_FILE = new URL('../../deploy/egress/systemd/maestro-egress.service.example', import.meta.url);
const SQUID_FILE = new URL('../../deploy/egress/squid.conf', import.meta.url);
const ALLOWED_HOST = 'allowed.egress.invalid';
const DENIED_HOST = 'denied.egress.invalid';
const ALLOWED_PORT = 443;
const SECRET = 'EGRESS_SECRET_CANARY_7d7fc02c';
function listen(server, host) {
return new Promise((resolve, reject) => {
server.once('error', reject);
const onReady = () => {
server.off('error', reject);
resolve(server.address());
};
if (typeof server.listen === 'function') server.listen(0, host, onReady);
else server.bind(0, host, onReady);
});
}
function close(server) {
server.closeAllConnections?.();
server.close();
}
function runNode(code, env = {}) {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
['--use-env-proxy', '-e', code],
{
env: { PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8', ...env },
encoding: 'utf8',
timeout: 8_000,
maxBuffer: 64 * 1024,
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(`local child probe failed: ${stderr.trim() || error.message}`));
return;
}
resolve(stdout.trim());
},
);
});
}
function rawExchange(host, port, request) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
let response = '';
const timer = setTimeout(() => socket.destroy(new Error('raw probe timed out')), 3_000);
socket.setEncoding('utf8');
socket.on('connect', () => socket.end(request));
socket.on('data', (chunk) => { response += chunk; });
socket.on('error', reject);
socket.on('close', () => {
clearTimeout(timer);
resolve(response);
});
});
}
function parseTarget(value, fallbackScheme = 'http:') {
try {
const url = value.includes('://') ? new URL(value) : new URL(`${fallbackScheme}//${value}`);
return {
hostname: url.hostname,
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
};
} catch {
return { hostname: '', port: 0 };
}
}
function allowedTarget(target) {
return target.hostname === ALLOWED_HOST && target.port === ALLOWED_PORT;
}
async function validateTemplates() {
const [unit, squid] = await Promise.all([
readFile(SYSTEMD_FILE, 'utf8'),
readFile(SQUID_FILE, 'utf8'),
]);
assert.match(unit, /^IPAddressDeny=any$/m);
assert.match(unit, /^IPAddressAllow=127\.0\.0\.2\/32$/m);
assert.match(unit, /^IPAddressAllow=127\.0\.0\.3\/32$/m);
assert.doesNotMatch(unit, /^IPAddressAllow=(?:localhost|127\.0\.0\.0\/8|::1(?:\/128)?)$/m);
assert.match(unit, /ExecStart=\/usr\/bin\/env .*HTTP_PROXY=http:\/\/127\.0\.0\.2:3128 .*HTTPS_PROXY=http:\/\/127\.0\.0\.2:3128 .*NO_PROXY= .*\/usr\/bin\/node --use-env-proxy/m);
assert.doesNotMatch(unit, /NODE_OPTIONS=/);
assert.match(squid, /^http_port 127\.0\.0\.2:3128$/m);
assert.match(squid, /^acl maestro_llm_host dstdomain -n allowed\.egress\.invalid$/m);
assert.match(squid, /^acl maestro_llm_port port 443$/m);
assert.match(squid, /^http_access allow maestro_llm_host maestro_llm_port$/m);
assert.match(squid, /^access_log none$/m);
assert.match(squid, /^cache_store_log none$/m);
const accessRules = squid.split(/\r?\n/).filter((line) => /^http_access\s+/.test(line.trim()));
assert.equal(accessRules.at(-1)?.trim(), 'http_access deny all');
return { systemd: true, squid: true };
}
async function runProxyChecks() {
const events = [];
const proxy = http.createServer((req, res) => {
const selectedTarget = Array.isArray(req.headers['x-egress-target'])
? req.headers['x-egress-target'][0]
: req.headers['x-egress-target'];
const target = parseTarget(selectedTarget || req.url || '');
const allowed = allowedTarget(target);
events.push({ protocol: 'http', decision: allowed ? 'allow' : 'deny', port: target.port });
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain' });
res.end(allowed ? 'allowed' : 'denied');
});
proxy.on('connect', (req, socket) => {
const target = parseTarget(req.url || '', 'http:');
const allowed = allowedTarget(target);
events.push({ protocol: 'connect', decision: allowed ? 'allow' : 'deny', port: target.port });
socket.end(`HTTP/1.1 ${allowed ? '200 Connection Established' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
});
proxy.on('upgrade', (req, socket) => {
const target = parseTarget(req.url || '');
const allowed = allowedTarget(target);
events.push({ protocol: 'websocket', decision: allowed ? 'allow' : 'deny', port: target.port });
socket.end(`HTTP/1.1 ${allowed ? '101 Switching Protocols' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
});
const address = await listen(proxy, '127.0.0.1');
const proxyUrl = `http://127.0.0.1:${address.port}`;
try {
const fetchAllowed = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${ALLOWED_HOST}:${ALLOWED_PORT}` },
signal: AbortSignal.timeout(3_000),
});
const fetchDenied = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${DENIED_HOST}:${ALLOWED_PORT}` },
signal: AbortSignal.timeout(3_000),
});
assert.equal(fetchAllowed.status, 200);
assert.equal(fetchDenied.status, 403);
await Promise.all([fetchAllowed.text(), fetchDenied.text()]);
const connectAllowed = await rawExchange('127.0.0.1', address.port,
`CONNECT ${ALLOWED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nAuthorization: Bearer ${SECRET}\r\n\r\n`);
const connectDenied = await rawExchange('127.0.0.1', address.port,
`CONNECT ${DENIED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\n\r\n`);
assert.match(connectAllowed, /^HTTP\/1\.1 200/);
assert.match(connectDenied, /^HTTP\/1\.1 403/);
const wsAllowed = await rawExchange('127.0.0.1', address.port,
`GET ws://${ALLOWED_HOST}:${ALLOWED_PORT}/socket?token=${SECRET} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
const wsDenied = await rawExchange('127.0.0.1', address.port,
`GET ws://${DENIED_HOST}:${ALLOWED_PORT}/socket HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
assert.match(wsAllowed, /^HTTP\/1\.1 101/);
assert.match(wsDenied, /^HTTP\/1\.1 403/);
const serializedEvents = JSON.stringify(events);
assert.ok(!serializedEvents.includes(SECRET), 'metadata events must not retain request secrets');
assert.equal(events.filter((event) => event.decision === 'allow').length, 3);
assert.equal(events.filter((event) => event.decision === 'deny').length, 3);
return { allowed: 3, denied: 3, secretRetained: false };
} finally {
close(proxy);
}
}
async function reserveTcpPort() {
const server = net.createServer();
const address = await listen(server, '127.0.0.1');
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
return address.port;
}
async function runActualEnvProxyChild(port) {
const events = [];
const proxy = http.createServer((req, res) => {
const target = parseTarget(req.url || '');
const allowed = allowedTarget(target);
events.push({
transport: 'absolute-form',
decision: allowed ? 'allow' : 'deny',
target: `${target.hostname}:${target.port}`,
absoluteUrl: /^http:\/\//.test(req.url || ''),
});
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain', connection: 'close' });
res.end(allowed ? 'allowed' : 'denied');
});
proxy.on('connect', (req, socket) => {
const target = parseTarget(req.url || '', 'http:');
const allowed = allowedTarget(target);
events.push({
transport: 'connect',
decision: allowed ? 'allow' : 'deny',
target: `${target.hostname}:${target.port}`,
absoluteUrl: false,
});
if (!allowed) {
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
return;
}
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
socket.once('data', () => {
socket.end('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 7\r\nConnection: close\r\n\r\nallowed');
});
});
await new Promise((resolve, reject) => {
proxy.once('error', reject);
proxy.listen(port, '127.0.0.1', () => {
proxy.off('error', reject);
resolve();
});
});
try {
const init = {
headers: { Authorization: `Bearer ${SECRET}` },
signal: AbortSignal.timeout(3_000),
};
const allowedResponse = await fetch(`http://${ALLOWED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
const allowedStatus = allowedResponse.status;
await allowedResponse.text();
let deniedStatus = 'error';
try {
const deniedResponse = await fetch(`http://${DENIED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
deniedStatus = deniedResponse.status;
await deniedResponse.text();
} catch {}
return { allowedStatus, deniedStatus, events };
} finally {
close(proxy);
}
}
async function runActualEnvProxyCheck() {
const port = await reserveTcpPort();
const proxyUrl = `http://127.0.0.1:${port}`;
const childEnv = {
PATH: process.env.PATH || '/usr/bin:/bin',
HOME: '/tmp',
LANG: 'C.UTF-8',
MAESTRO_EGRESS_PROXY_CHILD: '1',
MAESTRO_EGRESS_PROXY_PORT: String(port),
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
NO_PROXY: '',
};
const output = await new Promise((resolve, reject) => {
execFile(
process.execPath,
['--use-env-proxy', fileURLToPath(import.meta.url)],
{ env: childEnv, encoding: 'utf8', timeout: 8_000, maxBuffer: 64 * 1024 },
(error, stdout, stderr) => {
if (error) {
reject(new Error(`env proxy child failed: ${stderr.trim() || error.message}`));
return;
}
resolve(stdout.trim());
},
);
});
assert.ok(!output.includes(SECRET));
const result = JSON.parse(output);
assert.equal(result.allowedStatus, 200);
assert.ok(result.deniedStatus === 403 || result.deniedStatus === 'error');
const allowed = result.events.find((event) => event.decision === 'allow');
const denied = result.events.find((event) => event.decision === 'deny');
assert.equal(allowed?.target, `${ALLOWED_HOST}:${ALLOWED_PORT}`);
assert.equal(denied?.target, `${DENIED_HOST}:${ALLOWED_PORT}`);
if (allowed.transport === 'absolute-form') assert.equal(allowed.absoluteUrl, true);
return {
nodeFlag: '--use-env-proxy',
allowedTransport: allowed.transport,
allowedTarget: allowed.target,
deniedTarget: denied.target,
cleanChildEnvironment: true,
secretRetained: false,
};
}
async function runDirectBypassChecks() {
const hits = { http: 0, raw: 0, childRaw: 0, ipv6: 0, dns: 0 };
const httpServer = http.createServer((req, res) => {
hits.http += 1;
res.end('direct-http');
});
const rawServer = net.createServer((socket) => {
socket.once('data', (data) => {
const kind = data.toString() === 'child' ? 'childRaw' : 'raw';
hits[kind] += 1;
socket.end('ok');
});
});
const ipv6Server = net.createServer((socket) => {
hits.ipv6 += 1;
socket.end('ok');
});
const dnsServer = dgram.createSocket('udp4');
dnsServer.on('message', (message, remote) => {
hits.dns += 1;
const response = Buffer.from(message);
response[2] = 0x81;
response[3] = 0x83;
response.writeUInt16BE(0, 6);
response.writeUInt16BE(0, 8);
response.writeUInt16BE(0, 10);
dnsServer.send(response, remote.port, remote.address);
});
const servers = [httpServer, rawServer, ipv6Server, dnsServer];
try {
const httpAddress = await listen(httpServer, '127.0.0.1');
const rawAddress = await listen(rawServer, '127.0.0.1');
const ipv6Address = await listen(ipv6Server, '::1');
const dnsAddress = await listen(dnsServer, '127.0.0.1');
const directHttp = await new Promise((resolve, reject) => {
const request = http.get(`http://127.0.0.1:${httpAddress.port}/direct`, (response) => {
response.resume();
response.on('end', () => resolve(response.statusCode));
});
request.on('error', reject);
});
assert.equal(directHttp, 200);
assert.equal(await rawExchange('127.0.0.1', rawAddress.port, 'parent'), 'ok');
assert.equal(await rawExchange('::1', ipv6Address.port, 'ipv6'), 'ok');
const childResult = await runNode(`
const net = require('node:net');
const socket = net.createConnection({ host: '127.0.0.1', port: ${rawAddress.port} });
socket.on('connect', () => socket.end('child'));
socket.on('close', () => process.exit(0));
socket.on('error', (error) => { console.error(error); process.exit(1); });
`, {
HTTP_PROXY: 'http://127.0.0.1:9',
HTTPS_PROXY: 'http://127.0.0.1:9',
NO_PROXY: '',
});
assert.equal(childResult, '');
const resolver = new Resolver();
resolver.setServers([`127.0.0.1:${dnsAddress.port}`]);
await resolver.resolve4(`dns-${SECRET.toLowerCase()}.invalid`).catch(() => {});
assert.deepEqual(hits, { http: 1, raw: 1, childRaw: 1, ipv6: 1, dns: 1 });
return {
demonstrated: ['node:http', 'raw-tcp-ipv4', 'spawned-child-raw-tcp', 'raw-tcp-ipv6', 'direct-dns'],
meaning: 'these transports bypass HTTP_PROXY and must be stopped by the OS boundary',
};
} finally {
for (const server of servers) {
try { close(server); } catch {}
}
}
}
if (process.env.MAESTRO_EGRESS_PROXY_CHILD === '1') {
const port = Number(process.env.MAESTRO_EGRESS_PROXY_PORT);
assert.ok(Number.isInteger(port) && port > 0);
const childResult = await runActualEnvProxyChild(port);
const childOutput = JSON.stringify(childResult);
assert.ok(!childOutput.includes(SECRET));
console.log(childOutput);
process.exit(0);
}
const templates = await validateTemplates();
const envProxyRuntime = await runActualEnvProxyCheck();
const proxyPolicy = await runProxyChecks();
const proxyScope = await runDirectBypassChecks();
const result = {
ok: true,
externalNetworkUsed: false,
hostIsolationApplied: false,
templates,
envProxyRuntime,
proxyPolicy,
proxyScope,
};
const output = JSON.stringify(result, null, 2);
assert.ok(!output.includes(SECRET));
console.log(output);
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
// Real worker startup + browser CSP regression. All canaries are loopback-only.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { mkdtemp, writeFile, rm, mkdir, copyFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { chromium } from 'playwright';
import { WebSocketServer } from 'ws';
import express from 'express';
import { registerBrowserEgress } from '../../dist/bridge/egress-csp.js';
const root = resolve(import.meta.dirname, '../..');
const temporary = await mkdtemp(join(tmpdir(), 'maestro-egress-'));
const secret = 'egress-canary-secret-must-not-be-logged';
const servers = [];
let browser;
let worker;
async function listen(server) {
server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
async function stopWorker() {
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
const exiting = once(worker, 'exit'); worker.kill('SIGTERM');
const timer = setTimeout(() => worker?.kill('SIGKILL'), 5000);
try { await exiting; } finally { clearTimeout(timer); worker = undefined; }
}
try {
await mkdir(join(temporary, 'dist/db'), { recursive: true });
await copyFile(join(root, 'dist/db/schema.sql'), join(temporary, 'dist/db/schema.sql'));
const invalidPolicy = join(temporary, 'invalid-policy.json');
await writeFile(invalidPolicy, JSON.stringify({ secret }));
for (const entry of ['main.js', 'index.js']) {
const invalidStart = spawnSync(process.execPath, [join(root, 'dist', entry)], {
cwd: temporary, env: { PATH: process.env.PATH, HOME: temporary, AAO_EGRESS_POLICY: invalidPolicy },
encoding: 'utf8', timeout: 5000,
});
assert.equal(invalidStart.status, 1, 'invalid policy rejects startup');
assert(invalidStart.stderr.includes('Invalid egress policy'));
assert(!invalidStart.stderr.includes(secret));
assert(!invalidStart.stdout.includes('maestro starting'), 'no worker startup with invalid policy');
}
let allowedHits = 0, deniedHits = 0;
const makeCanary = hit => {
const server = createServer((req, res) => {
hit(); res.setHeader('Access-Control-Allow-Origin', '*');
if (req.url.startsWith('/image')) {
res.setHeader('Content-Type', 'image/svg+xml'); res.end('<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="2" height="2"/></svg>');
} else { res.end('ok'); }
});
const ws = new WebSocketServer({ server }); ws.on('connection', socket => { hit(); socket.close(); });
return server;
};
const allowed = await listen(makeCanary(() => allowedHits++));
const denied = await listen(makeCanary(() => deniedHits++));
let llmHits = 0;
const llm = await listen(createServer((_req, res) => { llmHits++; res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"egress-test-model"}],"models":[]}'); }));
const policyPath = join(temporary, 'policy.json');
const configPath = join(temporary, 'config.yaml');
await writeFile(configPath, 'safety:\n bash_allow_network: true\n');
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
for (const mode of ['report-only', 'enforce']) {
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
const base = `http://127.0.0.1:${port}`;
const policy = { version: 1, observeNode: true, browser: {
mode, connectOrigins: [allowed, allowed.replace('http:', 'ws:')], imageOrigins: [allowed], frameOrigins: [],
} };
await writeFile(policyPath, JSON.stringify(policy));
const fixtureApp = express();
registerBrowserEgress(fixtureApp, policy);
fixtureApp.get('/sandbox', (_req, res) => {
res.setHeader('Content-Security-Policy', 'sandbox');
res.type('html').send(`<img id=canary src="${denied}/image?token=${secret}">`);
});
fixtureApp.get('/', (_req, res) => res.type('html').send('<iframe src="/sandbox"></iframe>'));
const fixture = await listen(createServer(fixtureApp));
const initialLlmHits = llmHits;
let output = '';
worker = spawn(process.execPath, [join(root, 'dist/main.js')], {
cwd: temporary,
// Do not inherit real integrations, credentials, proxies, preload scripts or developer config.
env: { PATH: process.env.PATH, HOME: temporary, NODE_ENV: 'test', AAO_MODE: 'worker',
AAO_CONFIG: configPath, AAO_EGRESS_POLICY: policyPath, PORT: String(port), LOG_LEVEL: 'info',
DB_PATH: join(temporary, `${mode}.db`), WORKTREE_DIR: join(temporary, `${mode}-workspaces`),
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'egress-test-model' },
stdio: ['ignore', 'pipe', 'pipe'],
});
worker.on('error', error => { output += error.message; });
const capture = chunk => { output += chunk.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
let ready = false;
for (let attempt = 0; attempt < 300; attempt++) {
if (worker.exitCode !== null) throw new Error(`Worker failed to start: ${output.slice(-6000)}`);
try { const response = await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) }); if (response.ok) { ready = true; break; } } catch {}
await delay(100);
}
assert(ready, `worker ready (${mode})`);
const context = await browser.newContext({ serviceWorkers: 'block' });
context.setDefaultTimeout(15000);
const unexpected = [];
const pageErrors = [];
await context.route('**/*', route => {
const url = route.request().url();
if (!/^https?:/.test(url) || [base, allowed, denied, fixture].includes(new URL(url).origin)) return route.continue();
unexpected.push(new URL(url).origin); return route.abort();
});
await context.routeWebSocket('**/*', route => {
const origin = new URL(route.url()).origin.replace(/^ws/, 'http');
if ([base, allowed, denied, fixture].includes(origin)) route.connectToServer();
else { unexpected.push(origin); route.close(); }
});
const page = await context.newPage();
// App errors only: Playwright's service-worker disabling init script itself
// throws inside an intentionally opaque sandbox frame. The fixture has no scripts.
page.on('pageerror', error => pageErrors.push(error.message));
const response = await page.goto(`${base}/ui/`);
const header = mode === 'enforce' ? 'content-security-policy' : 'content-security-policy-report-only';
assert(response.headers()[header]?.includes('connect-src'), `${mode} CSP on real UI`);
await page.getByTestId('nav-spaces').waitFor({ state: 'visible' });
await page.getByTestId('nav-spaces').click();
await page.getByTestId('space-rail').waitFor({ state: 'visible' });
const attempt = async (origin, kind) => page.evaluate(async ({ origin, kind, secret }) => {
const url = `${origin}/${kind}?token=${secret}`;
return await new Promise(resolveResult => {
const timeout = setTimeout(() => resolveResult('timeout'), 4000);
const finish = value => { clearTimeout(timeout); resolveResult(value); };
if (kind === 'fetch') fetch(url).then(r => finish(r.ok ? 'ok' : 'error'), () => finish('error'));
else if (kind === 'image') { const img = new Image(); img.onload = () => finish('ok'); img.onerror = () => finish('error'); img.src = url; }
else { const socket = new WebSocket(url.replace('http:', 'ws:')); socket.onopen = () => { socket.close(); finish('ok'); }; socket.onerror = () => finish('error'); }
});
}, { origin, kind, secret });
const startAllowed = allowedHits, startDenied = deniedHits;
for (const kind of ['fetch', 'image', 'websocket']) {
assert.equal(await attempt(allowed, kind), 'ok', `${mode} allowed ${kind}`);
assert.equal(await attempt(denied, kind), mode === 'enforce' ? 'error' : 'ok', `${mode} unlisted ${kind}`);
}
assert.equal(allowedHits - startAllowed, 3);
assert.equal(deniedHits - startDenied, mode === 'enforce' ? 0 : 3);
const sandboxPage = await context.newPage();
const sandboxResponse = await context.request.get(`${fixture}/sandbox`);
assert(sandboxResponse.headers()['content-security-policy'].includes('sandbox'), 'sandbox is preserved');
assert(sandboxResponse.headers()[header].includes('img-src'), 'egress CSP survives later sandbox header');
const beforeFrame = deniedHits;
await sandboxPage.goto(fixture);
const canary = sandboxPage.frameLocator('iframe').locator('#canary');
await canary.waitFor({ state: 'attached' });
for (let i = 0; i < 50 && !await canary.evaluate(img => img.complete); i++) await delay(100);
assert(await canary.evaluate(img => img.complete), 'iframe image request completes');
assert.equal(await canary.evaluate(img => img.naturalWidth > 0), mode === 'report-only', 'same-origin sandbox iframe egress');
assert.equal(deniedHits - beforeFrame, mode === 'enforce' ? 0 : 1);
await sandboxPage.close();
for (let i = 0; i < 30 && !output.includes(`[security:csp] blocked="${denied}"`); i++) await delay(100);
assert(output.includes(`[security:csp] blocked="${denied}"`), 'browser reports reach real worker');
assert(!output.includes(secret), 'canary secret absent from all worker logs');
assert.deepEqual(unexpected, [], 'normal UI must not contact unknown origins');
// Stay idle across the observation interval and backend status refresh cycle.
if (mode === 'enforce') {
for (let i = 0; i < 13; i++) { await delay(5000); assert.equal(worker.exitCode, null); }
assert(output.includes('[egress-summary]'), 'idle observation summary');
}
assert(output.includes('[egress] Node destination observation enabled'));
const observationLines = output.split('\n').filter(line => /\[egress(?:-summary)?\]/.test(line));
assert(!observationLines.some(line => line.includes(secret)), 'secrets absent from observations');
let sawLlmHttp = false, sawLlmTcp = false;
for (const line of observationLines) {
const json = line.slice(line.indexOf('] {') + 2);
if (!json.startsWith('{')) continue;
const value = JSON.parse(json);
const entries = value.entries ?? [value];
for (const entry of entries) {
sawLlmHttp ||= entry.kind === 'http' && entry.destination === llm;
sawLlmTcp ||= entry.kind === 'tcp' && entry.destination === llm.replace('http:', 'tcp:');
assert([llm, allowed, denied, base].includes(entry.destination)
|| [llm, allowed, denied, base].map(origin => origin.replace('http:', 'tcp:')).includes(entry.destination), 'only controlled destinations observed');
}
}
assert(llmHits > initialLlmHits, 'worker actually contacts fake LLM');
assert(sawLlmHttp && sawLlmTcp, 'real worker HTTP and TCP observations are nonempty');
assert.deepEqual(pageErrors, [], 'normal UI has no uncaught errors');
assert.deepEqual(unexpected, [], 'idle UI has no unexpected destinations');
await context.close(); await stopWorker();
console.log(`${mode}: real UI startup, HTTP/image/WebSocket canaries, destination-only observation passed`);
}
} finally {
await stopWorker(); await browser?.close();
for (const server of servers) { server.closeAllConnections(); server.close(); }
await rm(temporary, { recursive: true, force: true });
}
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
// Real Settings UI + persisted config + live CSP. Only loopback test services.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { mkdtemp, mkdir, copyFile, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
const root = resolve(import.meta.dirname, '../..');
const temp = await mkdtemp(join(tmpdir(), 'maestro-egress-settings-'));
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
let worker, browser;
let output = '';
const servers = [];
async function listen(server) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server); return `http://127.0.0.1:${server.address().port}`; }
async function stop() {
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
const done = once(worker, 'exit'); worker.kill('SIGTERM');
const timeout = setTimeout(() => worker?.kill('SIGKILL'), 5000);
try { await done; } finally { clearTimeout(timeout); worker = undefined; }
}
try {
await mkdir(join(temp, 'dist/db'), { recursive: true });
await copyFile(join(root, 'dist/db/schema.sql'), join(temp, 'dist/db/schema.sql'));
let hits = 0;
const canary = await listen(createServer((_req, res) => { hits++; res.setHeader('Access-Control-Allow-Origin', '*'); res.end('ok'); }));
const llm = await listen(createServer((_req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"test-model"}],"models":[]}'); }));
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
const base = `http://127.0.0.1:${port}`;
const config = join(temp, 'config.yaml');
await writeFile(config, 'safety:\n bash_allow_network: true\n');
const env = { PATH: process.env.PATH, HOME: temp, NODE_ENV: 'test', AAO_MODE: 'worker', AAO_CONFIG: config,
PORT: String(port), DB_PATH: join(temp, 'test.db'), WORKTREE_DIR: join(temp, 'workspaces'),
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'test-model', LOG_LEVEL: 'info' };
async function start(extra = {}) {
output = '';
worker = spawn(process.execPath, [join(root, 'dist/main.js')], { cwd: temp, env: { ...env, ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
const capture = value => { output += value.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
worker.on('error', error => { output += error.message; });
for (let i = 0; i < 300; i++) {
if (worker.exitCode !== null) throw new Error(`worker startup failed: ${output.slice(-5000)}`);
try { if ((await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) })).ok) return; } catch {}
await delay(100);
}
throw new Error('worker did not start');
}
await start();
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const context = await browser.newContext({ locale: 'ja-JP', serviceWorkers: 'block' });
context.setDefaultTimeout(15000);
const external = [];
await context.route('**/*', route => {
const origin = new URL(route.request().url()).origin;
if ([base, canary].includes(origin)) return route.continue();
external.push(origin); return route.abort();
});
await context.routeWebSocket('**/*', route => {
if (new URL(route.url()).origin.replace(/^ws/, 'http') === base) route.connectToServer();
else { external.push(new URL(route.url()).origin); route.close(); }
});
const page = await context.newPage();
await page.goto(`${base}/ui/?page=settings&section=egress-policy`);
const mode = page.getByRole('combobox', { name: 'ブラウザの通信', exact: true });
const observe = page.getByRole('checkbox', { name: 'サーバー通信の接続先を記録', exact: true });
const connectOrigins = page.getByRole('textbox', { name: '接続の許可先', exact: true });
await mode.waitFor();
assert.equal(await mode.inputValue(), 'off');
const pid = worker.pid;
async function save() {
const response = page.waitForResponse(r => r.url() === `${base}/api/config` && r.request().method() === 'PUT');
await page.getByRole('button', { name: '保存して反映', exact: true }).click();
assert.equal((await response).status(), 200, 'settings save succeeds');
assert.equal(worker.pid, pid, 'settings apply without restarting worker');
}
async function attempt() { return page.evaluate(url => fetch(url).then(r => r.ok, () => false), canary); }
await mode.selectOption('report-only'); await observe.check(); await save();
assert((await fetch(`${base}/ui/`)).headers.get('content-security-policy-report-only')?.includes('connect-src'));
await page.reload(); assert.equal(await attempt(), true, 'report-only does not block');
assert(output.includes('Node destination observation enabled'), 'Node monitoring enabled live');
await mode.selectOption('enforce'); await save();
await page.reload(); const beforeBlocked = hits;
assert.equal(await attempt(), false, 'enforce blocks unlisted destination');
assert.equal(hits, beforeBlocked, 'blocked request never reaches destination');
await connectOrigins.fill(canary); await save();
await page.reload(); assert.equal(await attempt(), true, 'adding an allowed origin works');
const onDisk = await readFile(config, 'utf8');
assert(onDisk.includes('observe_node: true') && onDisk.includes('mode: enforce'), 'settings persisted to YAML');
await stop(); await start(); await page.reload();
assert.equal(await mode.inputValue(), 'enforce');
assert.equal(await connectOrigins.inputValue(), canary);
assert.equal(await observe.isChecked(), true);
assert.equal(await attempt(), true, 'saved policy survives restart');
const locked = join(temp, 'external.json');
await writeFile(locked, JSON.stringify({ version: 1, observeNode: false, browser: { mode: 'report-only', connectOrigins: [], imageOrigins: [], frameOrigins: [] } }));
await stop(); await start({ AAO_EGRESS_POLICY: locked }); await page.reload();
assert.equal(await mode.inputValue(), 'report-only');
assert.equal(await mode.isDisabled(), true); assert.equal(await observe.isDisabled(), true); assert.equal(await connectOrigins.isDisabled(), true);
const beforeWrite = await readFile(config, 'utf8');
const rejected = await fetch(`${base}/api/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ egress: { browser: { mode: 'off' } } }) });
assert.equal(rejected.status, 400, 'generic API cannot bypass external lock');
assert.equal(await readFile(config, 'utf8'), beforeWrite);
assert.deepEqual(external, []);
await context.close();
console.log('Settings UI: live report/enforce/allowlist, Node monitoring, persistence, external lock passed');
} finally {
await stop(); await browser?.close();
for (const server of servers) { server.closeAllConnections(); server.close(); }
await rm(temp, { recursive: true, force: true });
}
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env node
/**
* Browser regression check for UI assets that must remain local.
*
* This deliberately builds a throwaway Vite app from the production
* components. It does not inspect source strings: Chromium must load the
* bundled fonts, Monaco worker, and Leaflet marker images, edit and save a
* file, and render a map. OSM tiles are intercepted and fulfilled locally;
* every other cross-origin request is treated as a failure.
*/
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const scriptDir = dirname(fileURLToPath(import.meta.url));
process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true';
const repoRoot = resolve(scriptDir, '../..');
const uiRoot = join(repoRoot, 'ui');
const cspMode = process.argv.includes('--csp');
const uiNodeModules = join(uiRoot, 'node_modules');
const importFrom = async (path) => import(pathToFileURL(path).href);
const vite = await importFrom(join(uiNodeModules, 'vite/dist/node/index.js'));
const reactPluginModule = await importFrom(join(uiNodeModules, '@vitejs/plugin-react/dist/index.js'));
const require = createRequire(import.meta.url);
const playwrightModule = await importFrom(require.resolve('playwright'));
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
const reactPlugin = reactPluginModule.default;
const localModule = (name) => join(uiNodeModules, name);
const MONACO = join(uiRoot, 'src/components/userfolder/MonacoFileEditor.tsx');
const MAP = join(uiRoot, 'src/components/embed/MapPlacesDetail.tsx');
const CSS = join(uiRoot, 'src/index.css');
const POSTCSS = join(uiRoot, 'postcss.config.js');
class ExternalRequestDetected extends Error {
constructor(urls) {
super(`external requests blocked: ${urls.join(', ')}`);
this.name = 'ExternalRequestDetected';
}
}
const EXPECTED_CDN_URL = 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap';
function assertNoExternal(urls) {
if (urls.length > 0) throw new ExternalRequestDetected(urls);
}
const TILE_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
function fixtureSource() {
return `<!doctype html>
<html lang="ja">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>#root{min-height:900px}</style>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>`;
}
function fixtureAppSource() {
return `import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import i18next from 'i18next';
import { MonacoFileEditor } from ${JSON.stringify(MONACO)};
import { MapPlacesDetail } from ${JSON.stringify(MAP)};
import ${JSON.stringify(CSS)};
import jaUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/ja/userfolder.json"))};
import enUserfolder from ${JSON.stringify(join(uiRoot, "src/i18n/locales/en/userfolder.json"))};
const i18n = i18next.createInstance();
await i18n.init({
lng: 'ja',
fallbackLng: 'en',
resources: {
ja: { userfolder: jaUserfolder, embed: { searchResults: { map: '地図検索結果: 「{{query}}」' }, viewOn: { osm: 'OpenStreetMap で開く' } } },
en: { userfolder: enUserfolder, embed: { searchResults: { map: 'Map search results: “{{query}}”' }, viewOn: { osm: 'Open in OpenStreetMap' } } },
},
});
const MAP_DATA = {
query: '東京駅',
places: [
{ name: '東京駅', address: '東京都千代田区', lat: 35.681236, lon: 139.767125, type: 'station', details: '中央駅', mapUrl: 'https://www.openstreetmap.org/?mlat=35.681236&mlon=139.767125#map=16/35.681236/139.767125' },
{ name: '皇居', address: '東京都千代田区', lat: 35.685175, lon: 139.752799, type: 'park', details: '公園', mapUrl: 'https://www.openstreetmap.org/' },
],
};
function App() {
const [saved, setSaved] = useState('');
const [dirty, setDirty] = useState(false);
return <I18nextProvider i18n={i18n}>
<main style={{ minHeight: '900px', padding: 16 }}>
<h1>Local UI asset probe</h1>
<div data-font="ibm-sans" style={{ fontFamily: '"IBM Plex Sans JP"' }}>日本語フォント</div>
<div data-font="ibm-mono" style={{ fontFamily: '"IBM Plex Mono"' }}>IBM mono 0123</div>
<div data-font="jetbrains" style={{ fontFamily: '"JetBrains Mono"' }}>JetBrains mono 0123</div>
<section style={{ height: 300, border: '1px solid #ddd' }}>
<MonacoFileEditor
subdir="agents-md"
filename="AGENTS.md"
content="# original\\n"
mtime="2026-01-01T00:00:00.000Z"
size={12}
onSave={async (value) => { setSaved(value); await new Promise(resolve => setTimeout(resolve, 150)); }}
onDirtyChange={setDirty}
/>
</section>
<output data-testid="dirty-state">{String(dirty)}</output>
<output data-testid="save-result">{saved}</output>
<MapPlacesDetail data={MAP_DATA} />
</main>
</I18nextProvider>;
}
createRoot(document.getElementById('root')).render(<App />);
`;
}
async function makeFixture() {
const root = await mkdtemp(join(tmpdir(), 'maestro-local-ui-assets-'));
await mkdir(join(root, 'src'), { recursive: true });
await writeFile(join(root, 'index.html'), fixtureSource());
await writeFile(join(root, 'src/main.tsx'), fixtureAppSource());
return root;
}
async function buildFixture(root, outDir) {
const previousCwd = process.cwd();
process.chdir(uiRoot);
try {
await vite.build({
configFile: false,
root,
base: '/ui/',
plugins: [reactPlugin()],
resolve: {
dedupe: ['react', 'react-dom'],
alias: [
{ find: /^react$/, replacement: localModule('react') },
{ find: /^react\/(.*)$/, replacement: `${localModule('react')}/$1` },
{ find: /^react-dom$/, replacement: localModule('react-dom') },
{ find: /^react-dom\/(.*)$/, replacement: `${localModule('react-dom')}/$1` },
{ find: /^react-i18next$/, replacement: localModule('react-i18next') },
{ find: /^i18next$/, replacement: localModule('i18next') },
],
},
css: { postcss: POSTCSS },
logLevel: 'silent',
build: {
outDir,
emptyOutDir: true,
rollupOptions: { input: join(root, 'index.html') },
},
});
} finally {
process.chdir(previousCwd);
}
}
async function closePreview(previewInfo) {
const preview = previewInfo?.preview ?? previewInfo;
if (!preview) return;
if (typeof preview.close === 'function') {
await preview.close();
return;
}
if (preview.httpServer) {
await new Promise((resolveClose) => preview.httpServer.close(resolveClose));
}
}
async function runPreview(root) {
const headers = {};
if (cspMode) {
const { buildBrowserCsp } = await importFrom(join(repoRoot, 'dist/bridge/egress-csp.js'));
headers['Content-Security-Policy'] = buildBrowserCsp({
mode: 'enforce', connectOrigins: [], imageOrigins: ['https://tile.openstreetmap.org'], frameOrigins: [],
});
}
const preview = await vite.preview({
configFile: false,
root,
base: '/ui/',
preview: { host: '127.0.0.1', port: 0, strictPort: false, headers },
});
const address = preview.httpServer.address();
if (!address || typeof address === 'string') throw new Error('Vite preview did not expose a TCP address');
return { preview, origin: `http://127.0.0.1:${address.port}` };
}
async function browserCheck() {
const root = await makeFixture();
const outDir = join(root, 'dist');
let preview;
let browser;
let context;
try {
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
context = await browser.newContext({ serviceWorkers: 'block' });
await buildFixture(root, outDir);
preview = await runPreview(root);
const page = await context.newPage();
const requests = [];
const external = [];
const osm = [];
const localFailures = [];
const pageErrors = [];
const consoleErrors = [];
const workerResponses = [];
const local = new URL(preview.origin);
context.on('requestfailed', (request) => {
const url = new URL(request.url());
if (url.origin === local.origin) localFailures.push(`request failed ${request.url()}: ${request.failure()?.errorText ?? 'unknown'}`);
});
context.on('response', (response) => {
const url = new URL(response.url());
if (url.origin === local.origin && response.status() >= 400) {
localFailures.push(`HTTP ${response.status()} ${response.url()}`);
}
if (url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok()) {
workerResponses.push(response.url());
}
});
page.on('pageerror', (error) => pageErrors.push(String(error)));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
// This static fixture needs no WebSocket connection. Intercept before any
// server connection so a future dependency cannot bypass the HTTP guard.
await context.routeWebSocket('**/*', socket => {
external.push(socket.url());
socket.close();
});
await context.route('**/*', async (route) => {
const url = new URL(route.request().url());
requests.push(url.toString());
if (url.origin === local.origin) return route.continue();
if (url.hostname === 'tile.openstreetmap.org') {
osm.push(url.toString());
return route.fulfill({ status: 200, contentType: 'image/png', body: TILE_PNG });
}
external.push(url.toString());
return route.abort('blockedbyclient');
});
await page.goto(`${preview.origin}/ui/`, { waitUntil: 'domcontentloaded' });
assertNoExternal(external);
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
}
await page.locator('[data-testid="monaco-file-editor"]').waitFor();
await page.locator('.leaflet-container').waitFor();
try {
await page.waitForFunction(() => Boolean(document.querySelector('.monaco-editor [role="textbox"]') || document.querySelector('[data-testid="monaco-load-error"]')), undefined, { timeout: 45000 });
} catch (error) {
const editorState = await page.locator('[data-testid="monaco-file-editor"]').evaluate(element => ({ html: element.innerHTML, text: element.textContent }));
throw new Error(`Monaco did not mount: ${JSON.stringify({ editorState, localFailures, pageErrors, consoleErrors, requests: requests.slice(-30) })}`, { cause: error });
}
if (await page.locator('[data-testid="monaco-load-error"]').count() > 0) {
throw new Error(`Monaco lazy load failed: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
}
await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { timeout: 45000 });
await page.waitForTimeout(500);
const fontFamilies = await page.evaluate(async () => {
const names = ['IBM Plex Sans JP', 'IBM Plex Mono', 'JetBrains Mono'];
await Promise.all(names.map(name => document.fonts.load(`16px "${name}"`)));
return Object.fromEntries(names.map(name => [name, document.fonts.check(`16px "${name}"`)]));
});
if (Object.values(fontFamilies).some(value => value !== true)) {
throw new Error(`font faces did not load: ${JSON.stringify(fontFamilies)}`);
}
const fontRequests = requests.filter(url => /(?:plex|jetbrains)/i.test(url) && /\.(?:woff2?|ttf)(?:[?#]|$)/i.test(url));
if (new Set(fontRequests.map(url => /ibm-plex-sans-jp|ibm-plex-mono|jetbrains-mono/i.exec(url)?.[0])).size < 3) {
throw new Error(`expected all three local font families, got: ${fontRequests.join(', ')}`);
}
const editor = page.locator('.monaco-editor [role="textbox"]').first();
await editor.waitFor();
await editor.focus();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.type('# edited locally\\n');
await page.getByTestId('monaco-save').click();
// onSave can report persisted content before the child's dirty-state
// notification reaches the parent. Wait for both observable outcomes.
await page.waitForFunction(() =>
document.querySelector('[data-testid="save-result"]')?.textContent?.includes('edited locally') &&
document.querySelector('[data-testid="dirty-state"]')?.textContent === 'false');
await page.waitForFunction(() => document.querySelectorAll('.leaflet-marker-icon').length === 2);
const markerCount = await page.locator('.leaflet-marker-icon').count();
if (markerCount !== 2) throw new Error(`expected two rendered map markers, got ${markerCount}`);
await page.waitForFunction(() => [...document.querySelectorAll('.leaflet-marker-icon')].every(element => element instanceof HTMLImageElement && element.naturalWidth > 0 && element.naturalHeight > 0), undefined, { timeout: 30000 });
const markerSources = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => element.getAttribute('src') ?? ''));
const markerUrls = markerSources.map((src) => src.startsWith('data:') ? src : new URL(src, `${local.origin}/ui/`).href);
if (markerUrls.some(url => !url.startsWith(`${local.origin}/ui/`) && !url.startsWith('data:image/'))) {
throw new Error(`marker image escaped same-origin asset path: ${markerUrls.join(', ')}`);
}
const markerSizes = await page.locator('.leaflet-marker-icon').evaluateAll(elements => elements.map(element => ({
width: element instanceof HTMLImageElement ? element.naturalWidth : 0,
height: element instanceof HTMLImageElement ? element.naturalHeight : 0,
})));
if (markerSizes.some(size => size.width <= 0 || size.height <= 0)) {
throw new Error(`marker images did not decode: ${JSON.stringify(markerSizes)}`);
}
if (osm.length === 0) throw new Error('Leaflet did not request an OSM tile');
if (workerResponses.length === 0) {
await context.waitForEvent('response', {
predicate: response => {
const url = new URL(response.url());
return url.origin === local.origin && /(?:worker|\.worker)[^/]*\.js/i.test(url.pathname) && response.ok();
},
timeout: 10000,
});
}
assertNoExternal(external);
if (localFailures.length > 0 || pageErrors.length > 0 || consoleErrors.length > 0) {
throw new Error(`local UI asset failures: ${JSON.stringify({ localFailures, pageErrors, consoleErrors })}`);
}
const positive = {
requests: [...requests], external: [], osm: [...osm], fontRequests,
markerSources: markerUrls.map(url => url.startsWith('data:') ? 'data:image' : new URL(url).pathname),
};
// Inject only after the real components finish loading: this exercises
// the final guard, including requests that start after DOMContentLoaded.
await page.evaluate(url => {
document.addEventListener('securitypolicyviolation', event => {
if ((event.blockedURI === url || event.blockedURI === new URL(url).origin) && event.effectiveDirective === 'style-src-elem' && event.disposition === 'enforce') {
document.body.dataset.cspProbeBlocked = 'true';
}
});
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.onload = link.onerror = () => { document.body.dataset.cdnProbeFinished = 'true'; };
document.head.append(link);
}, EXPECTED_CDN_URL);
await page.waitForFunction(() => document.body.dataset.cdnProbeFinished === 'true', undefined, { timeout: 10000 });
let negativeCaught = false;
if (cspMode) {
await page.waitForFunction(() => document.body.dataset.cspProbeBlocked === 'true', undefined, { timeout: 10000 });
assertNoExternal(external);
negativeCaught = true;
} else try {
assertNoExternal(external);
} catch (error) {
if (!(error instanceof ExternalRequestDetected && external.length === 1 &&
external[0] === EXPECTED_CDN_URL)) throw error;
negativeCaught = true;
}
if (!negativeCaught) throw new Error('external CDN negative fixture was not rejected by the browser network guard');
return { ...positive, negativeExternalCdnDetected: negativeCaught };
} finally {
await context?.close();
await browser?.close();
await closePreview(preview);
await rm(root, { recursive: true, force: true });
}
}
const positive = await browserCheck();
console.log(JSON.stringify({
ok: true,
cspEnforced: cspMode,
sameOriginRequests: positive.requests.length - positive.external.length - positive.osm.length,
localFontFiles: positive.fontRequests.length,
osmTilesMocked: positive.osm.length,
markerSources: positive.markerSources,
negativeExternalCdnDetected: positive.negativeExternalCdnDetected,
}, null, 2));
+28
View File
@@ -0,0 +1,28 @@
import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
const workflowPath = new URL('../../.gitea/workflows/ci.yml', import.meta.url);
describe('untrusted pull request CI', () => {
it('does not use a privileged PR trigger or shared npm cache', async () => {
const workflow = await readFile(workflowPath, 'utf8');
expect(workflow).not.toMatch(/\bpull_request_target\s*:/);
expect(workflow).not.toMatch(/^\s+cache:\s*npm\s*$/m);
expect(workflow).toMatch(/\bpermissions:\s*\n\s+contents:\s*read\b/);
});
it('checks lockfiles before installing without lifecycle scripts', async () => {
const workflow = await readFile(workflowPath, 'utf8');
const policyIndex = workflow.indexOf('node scripts/security/dependency-policy.mjs');
const installIndex = workflow.indexOf('npm ci --ignore-scripts');
expect(policyIndex).toBeGreaterThan(-1);
expect(installIndex).toBeGreaterThan(policyIndex);
expect(workflow).toContain('npm --prefix ui ci --ignore-scripts');
expect(workflow).toContain('node scripts/security/rebuild-native.mjs');
expect(workflow).toContain('persist-credentials: false');
expect(workflow).not.toMatch(/^\s+npm ci\s*$/m);
});
});
+80
View File
@@ -0,0 +1,80 @@
const DIGEST_PIN = /^[^\s@]+:[^\s@]+@sha256:[a-f0-9]{64}$/;
const EXACT_PYTHON_PIN = /^[A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_,.-]+\])?==[^*<>=!~\s;]+(?:\s*;.+)?$/;
export function checkContainerPolicy(dockerfile) {
const violations = [];
for (const [index, line] of dockerfile.split(/\r?\n/).entries()) {
const match = line.match(/^\s*FROM\s+(\S+)/i);
if (!match || match[1].toLowerCase() === 'scratch') continue;
if (!DIGEST_PIN.test(match[1])) {
violations.push({ code: 'unpinned-image', line: index + 1, value: match[1] });
}
}
return violations;
}
/**
* Every `npm ci` / `npm install` in the image must carry `--ignore-scripts`.
*
* Why this check exists
* ---------------------
* The builder stage had `--ignore-scripts` from the start; the runtime stage —
* the one that actually ships — did not, and it installs `build-essential` on
* the same layer. A hijacked dependency's install script would have run there
* with a compiler in reach: the most permissive spot in the pipeline. The
* design and the PR both claimed install scripts were "off everywhere", so the
* gap was invisible in the prose (found reviewing PR #1074).
*
* Only RUN instructions are inspected, and comment lines are dropped first:
* this Dockerfile explains the Playwright bin collision in prose that mentions
* `npm ci` several times, and matching those would make the check cry wolf.
* Line continuations are joined, because the runtime install is one link in a
* long `&&` chain spread over many lines.
*/
export function checkDockerfileInstallPolicy(dockerfile) {
const violations = [];
const lines = dockerfile.split(/\r?\n/);
let buffer = '';
let startLine = 0;
let inRun = false;
for (const [index, raw] of lines.entries()) {
const line = raw.replace(/\s+#.*$/, '');
if (/^\s*#/.test(raw)) continue;
if (!inRun) {
if (!/^\s*RUN\s/i.test(line)) continue;
inRun = true;
startLine = index + 1;
buffer = '';
}
buffer += line.replace(/\\\s*$/, ' ');
if (/\\\s*$/.test(line)) continue;
for (const command of buffer.split(/&&|;|\|\|/)) {
if (!/\bnpm\b[^|]*\s(?:ci|install|i)(?:\s|$)/.test(command)) continue;
if (/--ignore-scripts\b/.test(command)) continue;
violations.push({
code: 'npm-install-runs-scripts',
line: startLine,
value: command.trim(),
});
}
buffer = '';
inRun = false;
}
return violations;
}
export function checkPythonRequirements(requirements) {
const violations = [];
for (const [index, rawLine] of requirements.split(/\r?\n/).entries()) {
const value = rawLine.replace(/\s+#.*$/, '').trim();
if (!value || value.startsWith('#')) continue;
if (value.startsWith('-') || value.includes(' @ ') || /(?:https?|git\+|file):/i.test(value)) {
violations.push({ code: 'non-index-python-dependency', line: index + 1, value });
continue;
}
if (!EXACT_PYTHON_PIN.test(value)) {
violations.push({ code: 'unpinned-python-dependency', line: index + 1, value });
}
}
return violations;
}
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
checkContainerPolicy,
checkDockerfileInstallPolicy,
checkPythonRequirements,
} from './container-policy.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
describe('container supply-chain policy', () => {
it('rejects base images that are not pinned by digest', () => {
expect(checkContainerPolicy('FROM node:22-alpine')).toContainEqual(
expect.objectContaining({ code: 'unpinned-image' }),
);
});
it('accepts base images pinned by tag and digest', () => {
expect(
checkContainerPolicy(
`FROM node:22-alpine@sha256:${'a'.repeat(64)} AS builder`,
),
).toEqual([]);
});
it.each([
['pypdf', 'unpinned-python-dependency'],
['pymupdf>=1.24', 'unpinned-python-dependency'],
['httpx~=0.28.0', 'unpinned-python-dependency'],
['PyYAML==6.0.*', 'unpinned-python-dependency'],
['package @ https://example.invalid/package.whl', 'non-index-python-dependency'],
])('rejects unsafe requirement %s', (requirement, code) => {
expect(checkPythonRequirements(requirement)).toContainEqual(
expect.objectContaining({ code }),
);
});
it('accepts an exact index version pin', () => {
expect(checkPythonRequirements('PyYAML==6.0.3 # YAML support')).toEqual([]);
});
it('accepts every external input in the production container files', () => {
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
const requirementFiles = [
'runtime/python-requirements.txt',
'scripts/x-adapter/requirements.txt',
];
expect(checkContainerPolicy(dockerfile)).toEqual([]);
for (const filename of requirementFiles) {
const contents = fs.readFileSync(path.join(root, filename), 'utf8');
expect(checkPythonRequirements(contents), filename).toEqual([]);
}
});
});
describe('install scripts in the image', () => {
// 出荷される runtime ステージだけ素の npm ci のままだった(PR #1074 のレビューで発覚)。
// しかも build-essential を同じ層で入れているので、乗っ取られた依存の導入時スクリプトが
// コンパイラごと使える。設計も PR 本文も「全部止めている」と書いていたので、
// 文章からは見えない穴だった。ここで固定する。
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
it('runs no npm install without --ignore-scripts anywhere in the image', () => {
expect(checkDockerfileInstallPolicy(dockerfile)).toEqual([]);
});
it.each([
['runtime(出荷物)', 'npm ci --omit=dev --ignore-scripts', 'npm ci --omit=dev'],
['builder', 'RUN npm ci --ignore-scripts', 'RUN npm ci'],
['builder の画面側', 'RUN npm --prefix ui ci --ignore-scripts', 'RUN npm --prefix ui ci'],
])('flags %s when --ignore-scripts is dropped', (_label, guarded, bare) => {
expect(dockerfile).toContain(guarded);
const violations = checkDockerfileInstallPolicy(dockerfile.replace(guarded, bare));
expect(violations).toContainEqual(
expect.objectContaining({ code: 'npm-install-runs-scripts' }),
);
});
it('does not cry wolf over prose that mentions npm ci', () => {
// この Dockerfile は Playwright の bin 衝突を長い散文で説明していて、
// その中に `npm ci` が何度も出てくる。コメントを拾うと常に赤になる。
const violations = checkDockerfileInstallPolicy(
['# NOTE: `npm ci` alone does NOT download the browser', 'RUN npm ci --ignore-scripts'].join('\n'),
);
expect(violations).toEqual([]);
});
it('keeps the allowlisted native rebuild next to the guarded install', () => {
// --ignore-scripts だけ入れて rebuild を忘れると、native が入らないまま出荷される。
expect(dockerfile).toContain('node scripts/security/rebuild-native.mjs');
expect(dockerfile).toContain('COPY security/install-scripts-allowlist.json');
expect(dockerfile).toContain('COPY scripts/security/rebuild-native.mjs');
});
});
@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import { checkLockfilePolicy } from './dependency-policy.mjs';
import { rebuildArgs, validateRebuildEntries, verify } from './rebuild-native.mjs';
const registryOrigin = 'https://registry.npmjs.org/';
describe('dependency policy adversarial cases', () => {
it('rejects a non-link package when resolved and integrity are both missing', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': {
version: '1.0.0',
hasInstallScript: true,
},
},
},
['node_modules/evil'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'missing-resolved',
name: 'node_modules/evil',
});
expect(violations).toContainEqual({
code: 'missing-integrity',
name: 'node_modules/evil',
});
});
it('rejects lookalike registry hostnames', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': {
version: '1.0.0',
resolved: 'https://registry.npmjs.org.evil.example/evil.tgz',
integrity: 'sha512-example',
},
},
},
[],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'non-registry-url',
name: 'node_modules/evil',
});
});
// このテストは名前で path / name / version / integrity の 4 つを縛ると言っている。
// ところが実際に起こしていたのは version の食い違いだけで、integrity 照合を
// 削っても 35 件すべて緑だった(PR #1074 のレビュー M8)。4 つを別々に起こす。
const soundLockfile = (over = {}) => ({
lockfileVersion: 3,
packages: {
'node_modules/native': {
version: '1.0.0',
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
integrity: 'sha512-approved',
hasInstallScript: true,
...over,
},
},
});
const soundApproval = (over = {}) => [{
path: 'node_modules/native',
name: 'native',
version: '1.0.0',
integrity: 'sha512-approved',
verifier: 'better-sqlite3-memory-db',
...over,
}];
it('accepts an approval that matches the lockfile on every field', () => {
expect(validateRebuildEntries(soundLockfile(), soundApproval())).toHaveLength(1);
});
it('rejects an integrity mismatch even when the version agrees', () => {
expect(() => validateRebuildEntries(soundLockfile({ integrity: 'sha512-swapped' }), soundApproval()))
.toThrow(/integrity mismatch/i);
});
it('rejects a target the lockfile says has no install script', () => {
// 許可一覧に「script を持たない依存」を書けると、rebuild だけが走る経路になる
expect(() => validateRebuildEntries(soundLockfile({ hasInstallScript: false }), soundApproval()))
.toThrow(/no install script/i);
});
it('rejects a path whose last node_modules segment is not the approved name', () => {
// 経路の取り出しは lastIndexOf でなければならない。indexOf に変えると
// node_modules/a/node_modules/b が "a/node_modules/b" になり、
// 名前の食い違いを見逃す
expect(() => validateRebuildEntries(
{
lockfileVersion: 3,
packages: {
'node_modules/outer/node_modules/native': {
version: '1.0.0',
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
integrity: 'sha512-approved',
hasInstallScript: true,
},
},
},
soundApproval({ path: 'node_modules/outer/node_modules/native', name: 'outer' }),
)).toThrow(/name mismatch/i);
});
it('accepts a nested path when the approved name matches its last segment', () => {
expect(validateRebuildEntries(
{
lockfileVersion: 3,
packages: {
'node_modules/outer/node_modules/native': {
version: '1.0.0',
resolved: 'https://registry.npmjs.org/native/-/native-1.0.0.tgz',
integrity: 'sha512-approved',
hasInstallScript: true,
},
},
},
soundApproval({ path: 'node_modules/outer/node_modules/native' }),
)).toHaveLength(1);
});
it('refuses a verifier it does not know, instead of silently skipping', () => {
// verify を即 return にしても 35 件すべて緑だった(レビュー M7)。
// 「実際にデータベースを開いて確かめる」が丸ごと消えても誰も気づかない状態。
expect(() => verify('no-such-verifier')).toThrow(/unsupported native rebuild verifier/i);
});
it('actually opens a database for the verifier this repository uses', () => {
// 中身が消えたら(即 return になったら)ここが空振りになるので、
// 検証が本当に走ったことを、走った先の失敗で見る
expect(() => verify('better-sqlite3-memory-db')).not.toThrow();
});
it('binds native rebuild permission to path, name, version, and integrity', () => {
const lockfile = {
lockfileVersion: 3,
packages: {
'node_modules/native': {
version: '2.0.0',
resolved: 'https://registry.npmjs.org/native/-/native-2.0.0.tgz',
integrity: 'sha512-new',
hasInstallScript: true,
},
},
};
const approved = [{
path: 'node_modules/native',
name: 'native',
version: '1.0.0',
integrity: 'sha512-approved',
verifier: 'better-sqlite3-memory-db',
}];
expect(() => validateRebuildEntries(lockfile, approved)).toThrow(/version mismatch/i);
});
});
describe('how npm rebuild is invoked', () => {
// 引数の並びに意味がある箇所。PR #1074 のレビューが、`--` を消しても
// 35 件すべて緑だったと報告した(M6)。
it('separates the package name from flags with --', () => {
const args = rebuildArgs('better-sqlite3');
expect(args[args.length - 2]).toBe('--');
expect(args[args.length - 1]).toBe('better-sqlite3');
});
it('builds from source instead of downloading a prebuilt binary', () => {
// 配布物を取ってこないぶん安全側。消すと取得物が 1 つ増える
expect(rebuildArgs('better-sqlite3')).toContain('--build-from-source');
});
});
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { posix as posixPath } from 'node:path';
import { pathToFileURL } from 'node:url';
export const TRUSTED_REGISTRY_ORIGIN = 'https://registry.npmjs.org/';
const DEFAULT_ALLOWLIST_PATH = 'security/install-scripts-allowlist.json';
export function isAllowedRegistryUrl(resolved, registryOrigin) {
try {
const url = new URL(resolved);
const registry = new URL(registryOrigin);
return (
url.protocol === registry.protocol &&
url.hostname === registry.hostname &&
url.port === registry.port &&
url.username === '' &&
url.password === '' &&
url.pathname.startsWith(registry.pathname)
);
} catch {
return false;
}
}
// npm はリポジトリ内の workspace を、ダウンロード物ではなくリポジトリ相対パスの
// キーで記録する(ルートの package-lock.json なら packages/foo、ui/ 配下からは
// ../packages/foo)。レジストリから取得する依存は必ず node_modules/ 区間を含む。
function isWorkspaceSourcePath(name) {
return !name.startsWith('node_modules/') && !name.includes('/node_modules/');
}
/**
* link の指す先が、リポジトリの内側に収まっているか。
*
* この関数が在る理由
* ------------------
* link を「同じ lockfile にキーとして在るか」だけで通していた時期がある。その条件は
* 歯止めにならない——lockfile を書ける側が link と実体キーの両方を書けるので、
* `node_modules/evil -> ../../../../tmp/payload` に同名のキーを併記するだけで、
* 取得元・ハッシュ・レジストリ判定のすべてを回避できた(PR #1074 のレビューで実証)。
*
* そこで、lockfile の置き場所を基準に解決して、リポジトリルートより上へ出ないことを
* 確かめる。`ui/package-lock.json` の `../packages/answer-worker-contract` は通り、
* それ以上さかのぼるものと絶対パスは止まる。
*
* @param resolved lockfile に書かれた link の指す先(リポジトリ相対)
* @param lockfileDir lockfile が置かれたディレクトリ(リポジトリルート基準・posix)
*/
export function isLinkInsideRepo(resolved, lockfileDir) {
if (typeof resolved !== 'string' || resolved === '') return false;
// 絶対パスと Windows のドライブ表記は、リポジトリ相対ではないので受け付けない。
if (posixPath.isAbsolute(resolved) || /^[A-Za-z]:/.test(resolved)) return false;
const joined = posixPath.normalize(posixPath.join(lockfileDir, resolved));
return joined !== '..' && !joined.startsWith('../');
}
export function checkLockfilePolicy(
lockfile,
allowlist,
registryOrigin = TRUSTED_REGISTRY_ORIGIN,
lockfileDir = '.',
) {
if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') {
throw new Error('dependency policy requires npm lockfile version 3 with a packages map');
}
if (!Array.isArray(allowlist) || !allowlist.every((entry) => typeof entry === 'string')) {
throw new Error('install script allowlist must be an array of lockfile package paths');
}
const allowed = new Set(allowlist);
const violations = [];
for (const [name, entry] of Object.entries(lockfile.packages)) {
if (!name || !entry || typeof entry !== 'object') continue;
// link は同じ lockfile 内に実体キーがある場合だけ認める。実体を持たない link は
// 追跡できない外部の入力を指すため、従来どおり拒否する。
// 実体キーが在ることに加えて、指す先がリポジトリの内側であることまで見る。
// 片方だけでは、lockfile を書ける側が両方を握って素通りできる。
const linkTargetIsInLockfile =
entry.link === true &&
typeof entry.resolved === 'string' &&
Object.hasOwn(lockfile.packages, entry.resolved) &&
isLinkInsideRepo(entry.resolved, lockfileDir);
const isWorkspaceSource = isWorkspaceSourcePath(name);
if (entry.link && !linkTargetIsInLockfile) {
violations.push({ code: 'unsupported-link', name });
} else if (!entry.link && !isWorkspaceSource && !entry.resolved) {
violations.push({ code: 'missing-resolved', name });
} else if (
!entry.link &&
entry.resolved &&
!isAllowedRegistryUrl(entry.resolved, registryOrigin)
) {
violations.push({ code: 'non-registry-url', name });
}
// workspace の実体にも install script 検査は残す(多層防御)。
if (entry.hasInstallScript && !allowed.has(name)) {
violations.push({ code: 'unapproved-install-script', name });
}
// リポジトリ内 workspace はダウンロードしないので integrity を持たない。
if (!entry.link && !isWorkspaceSource && !entry.integrity) {
violations.push({ code: 'missing-integrity', name });
}
}
// 許可一覧の腐敗検知。install script を持たなくなった依存が一覧に残ると、
// 将来その依存が再び script を持ったときに無審査で通ってしまう。
for (const path of allowlist) {
const entry = lockfile.packages[path];
if (!entry || !entry.hasInstallScript) {
violations.push({ code: 'stale-allowlist-entry', name: path });
}
}
return violations;
}
async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
export async function main(args) {
if (args.length === 0) {
throw new Error('usage: dependency-policy.mjs <package-lock.json> [...]');
}
const policy = await readJson(DEFAULT_ALLOWLIST_PATH);
let failed = false;
for (const lockfilePath of args) {
const lockfile = await readJson(lockfilePath);
const allowlist = policy.lockfiles?.[lockfilePath];
if (!Array.isArray(allowlist)) {
throw new Error(`no install script allowlist is defined for ${lockfilePath}`);
}
const lockfileDir = posixPath.dirname(lockfilePath.split('\\').join('/'));
const violations = checkLockfilePolicy(lockfile, allowlist, TRUSTED_REGISTRY_ORIGIN, lockfileDir);
for (const violation of violations) {
failed = true;
process.stderr.write(`${lockfilePath}: ${violation.code}: ${violation.name}\n`);
}
}
if (failed) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
+318
View File
@@ -0,0 +1,318 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { checkLockfilePolicy, isAllowedRegistryUrl, main } from './dependency-policy.mjs';
const registryOrigin = 'https://registry.npmjs.org/';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const readJson = (relative) =>
JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
describe('checkLockfilePolicy', () => {
it('rejects remote tarballs, missing integrity, and unapproved install scripts', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': {
resolved: 'https://evil.example/evil-1.0.0.tgz',
hasInstallScript: true,
},
},
},
[],
registryOrigin,
);
expect(violations).toEqual([
{ code: 'non-registry-url', name: 'node_modules/evil' },
{ code: 'unapproved-install-script', name: 'node_modules/evil' },
{ code: 'missing-integrity', name: 'node_modules/evil' },
]);
});
it('accepts an exact lockfile path that is explicitly allowlisted', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/native-package': {
resolved: 'https://registry.npmjs.org/native-package/-/native-package-1.0.0.tgz',
integrity: 'sha512-example',
hasInstallScript: true,
},
},
},
['node_modules/native-package'],
registryOrigin,
);
expect(violations).toEqual([]);
});
it('does not let a package-name allowlist approve a nested copy implicitly', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/parent/node_modules/native-package': {
resolved: 'https://registry.npmjs.org/native-package/-/native-package-2.0.0.tgz',
integrity: 'sha512-example',
hasInstallScript: true,
},
},
},
['node_modules/native-package'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'unapproved-install-script',
name: 'node_modules/parent/node_modules/native-package',
});
});
it('rejects unsupported lockfile formats instead of silently passing', () => {
expect(() =>
checkLockfilePolicy({ lockfileVersion: 2, packages: {} }, [], registryOrigin),
).toThrow(/lockfile version 3/i);
});
});
describe('this repository', () => {
// 許可一覧を実物の lockfile と突き合わせる。install script を持つ依存が増えた/
// 場所が変わった/一覧から消えた場合、CI の前に npm test で落ちる。
it.each(['package-lock.json', 'ui/package-lock.json'])(
'satisfies the install script policy for %s',
(lockfilePath) => {
const policy = readJson('security/install-scripts-allowlist.json');
// link の判定は lockfile の置き場所を基準に解決する。CI の入口
// (dependency-policy.mjs の main)と同じ値を渡さないと、ui/ の
// ../packages/... が「リポジトリ外」と判定されてしまう。
const lockfileDir = path.posix.dirname(lockfilePath);
const violations = checkLockfilePolicy(
readJson(lockfilePath),
policy.lockfiles[lockfilePath],
registryOrigin,
lockfileDir,
);
expect(violations).toEqual([]);
},
);
});
describe('in-repo workspace entries', () => {
const workspaceLockfile = {
lockfileVersion: 3,
packages: {
'node_modules/@scope/contract': { resolved: 'packages/contract', link: true },
'packages/contract': { name: '@scope/contract', version: '0.0.0' },
},
};
it('accepts a link whose target is a workspace source in the same lockfile', () => {
expect(checkLockfilePolicy(workspaceLockfile, [], registryOrigin)).toEqual([]);
});
it('accepts a parent-relative workspace path as npm writes it from a sub-package', () => {
// ui/package-lock.json から見た ../packages/... の形。lockfile が ui/ に在るので
// 1 つ上へ戻ってもリポジトリの内側に収まる。
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/@scope/contract': { resolved: '../packages/contract', link: true },
'../packages/contract': { name: '@scope/contract', version: '0.0.0' },
},
},
[],
registryOrigin,
'ui',
);
expect(violations).toEqual([]);
});
// ここから 3 件は、実体キーを併記すれば外部を指す link が素通りした穴を塞ぐもの
// (PR #1074 のレビューが実証)。lockfile を書ける側が link と実体キーの両方を
// 握れるので、「同じ lockfile に在るか」だけでは歯止めにならない。
it('rejects a link that escapes the repository even when its target key is present', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { resolved: '../../../../tmp/evil-payload', link: true },
'../../../../tmp/evil-payload': { name: 'evil', version: '1.0.0' },
},
},
[],
registryOrigin,
'.',
);
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
});
it('rejects an absolute link target even when its key is present', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { resolved: '/etc/evil', link: true },
'/etc/evil': { name: 'evil', version: '1.0.0' },
},
},
[],
registryOrigin,
'.',
);
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
});
it('rejects a sub-package link that climbs past the repository root', () => {
// ui/ から 2 つ上がればリポジトリの外。1 つまでなら正当(上のテスト)。
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { resolved: '../../outside/evil', link: true },
'../../outside/evil': { name: 'evil', version: '1.0.0' },
},
},
[],
registryOrigin,
'ui',
);
expect(violations).toContainEqual({ code: 'unsupported-link', name: 'node_modules/evil' });
});
it('still rejects a link whose target is absent from the lockfile', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/@scope/contract': { resolved: '../../outside/contract', link: true },
},
},
[],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'unsupported-link',
name: 'node_modules/@scope/contract',
});
});
it('still rejects an install script on a workspace source that is not allowlisted', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'packages/contract': { name: '@scope/contract', version: '0.0.0', hasInstallScript: true },
},
},
[],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'unapproved-install-script',
name: 'packages/contract',
});
});
});
describe('allowlist rot', () => {
it('reports an allowlisted path that no longer exists in the lockfile', () => {
const violations = checkLockfilePolicy(
{ lockfileVersion: 3, packages: {} },
['node_modules/removed'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'stale-allowlist-entry',
name: 'node_modules/removed',
});
});
it('reports an allowlisted package that no longer has an install script', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/calm': {
resolved: 'https://registry.npmjs.org/calm/-/calm-1.0.0.tgz',
integrity: 'sha512-example',
},
},
},
['node_modules/calm'],
registryOrigin,
);
expect(violations).toContainEqual({
code: 'stale-allowlist-entry',
name: 'node_modules/calm',
});
});
});
// レジストリ判定は 6 つの比較でできている。PR #1074 のレビューが、そのうち
// 4 つ(protocol / port / userinfo / pathname)をそれぞれ削っても 35 件すべて
// 緑のままだと報告した。効いていたのは hostname 一致だけ。判定そのものは
// 正しく動いているので、守るものが無いという指摘。1 つずつ固定する。
describe('registry url check — each comparison earns its keep', () => {
const ok = 'https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz';
it('accepts the real registry', () => {
expect(isAllowedRegistryUrl(ok, registryOrigin)).toBe(true);
});
it.each([
['protocol — 平文に落とす', 'http://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'],
['port — 別のポートへ向ける', 'https://registry.npmjs.org:8443/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — 利用者名だけ埋める', 'https://[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — 合言葉だけ埋める', 'https://:[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — 両方埋める', 'https://user:[email protected]/left-pad/-/left-pad-1.3.0.tgz'],
['userinfo — @ で別ホストへ逃がす', 'https://[email protected]/left-pad.tgz'],
['hostname — 似た名前の別ホスト', 'https://registry.npmjs.org.evil.example/left-pad.tgz'],
['hostname — 部分一致を狙う', 'https://evil-registry.npmjs.org.example/left-pad.tgz'],
])('rejects %s', (_label, url) => {
expect(isAllowedRegistryUrl(url, registryOrigin)).toBe(false);
});
it('rejects a registry origin with a path prefix when the url leaves it', () => {
// pathname 比較を消すと、別のパス配下でも通ってしまう
expect(isAllowedRegistryUrl('https://example.test/other/x.tgz', 'https://example.test/scoped/')).toBe(false);
expect(isAllowedRegistryUrl('https://example.test/scoped/x.tgz', 'https://example.test/scoped/')).toBe(true);
});
});
describe('the entry point CI actually runs', () => {
// main は CI が叩く唯一の入口なのに、どのテストからも呼ばれていなかった
// (PR #1074 のレビュー P2-2)。純関数だけ固めて配線が無テスト、の典型。
it('throws when a lockfile has no allowlist defined', async () => {
await expect(main(['no-such-lockfile-in-the-policy.json'])).rejects.toThrow();
});
it('requires at least one lockfile path', async () => {
await expect(main([])).rejects.toThrow(/usage/);
});
it('leaves the exit code alone when this repository passes', async () => {
const before = process.exitCode;
process.exitCode = 0;
await main(['package-lock.json', 'ui/package-lock.json']);
expect(process.exitCode).toBe(0);
process.exitCode = before;
});
});
@@ -0,0 +1,69 @@
import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { checkLockfilePolicy } from './dependency-policy.mjs';
import { validateRebuildEntries } from './rebuild-native.mjs';
const lockedNative = {
lockfileVersion: 3,
packages: {
'node_modules/better-sqlite3': {
version: '12.6.2',
resolved: 'https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz',
integrity: 'sha512-approved',
hasInstallScript: true,
},
},
};
function approval(name) {
return [{
path: 'node_modules/better-sqlite3',
name,
version: '12.6.2',
integrity: 'sha512-approved',
verifier: 'better-sqlite3-memory-db',
}];
}
describe('native rebuild adversarial cases', () => {
it('rejects a package name that does not match the approved lockfile path', () => {
expect(() => validateRebuildEntries(lockedNative, approval('sharp'))).toThrow(
/name mismatch/i,
);
});
it('rejects npm option injection as a package name', () => {
expect(() => validateRebuildEntries(lockedNative, approval('--prefix=/tmp/evil'))).toThrow(
/invalid package name|name mismatch/i,
);
});
it('rejects lockfile links because this repository has no workspaces', () => {
const violations = checkLockfilePolicy(
{
lockfileVersion: 3,
packages: {
'node_modules/evil': { link: true },
},
},
[],
);
expect(violations).toContainEqual({
code: 'unsupported-link',
name: 'node_modules/evil',
});
});
it('can be imported when a Node eval has no script argv', () => {
const result = spawnSync(
process.execPath,
['-e', "import('./scripts/security/rebuild-native.mjs')"],
{ cwd: process.cwd(), encoding: 'utf8' },
);
expect(result.status, result.stderr).toBe(0);
});
});
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
const LOCKFILE_PATH = 'package-lock.json';
const POLICY_PATH = 'security/install-scripts-allowlist.json';
const SUPPORTED_VERIFIERS = new Set(['better-sqlite3-memory-db']);
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
function packageNameFromPath(path) {
const marker = "node_modules/";
const index = path.lastIndexOf(marker);
return index === -1 ? "" : path.slice(index + marker.length);
}
export function validateRebuildEntries(lockfile, approvedEntries) {
if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') {
throw new Error('native rebuild requires npm lockfile version 3');
}
if (!Array.isArray(approvedEntries)) {
throw new Error('native rebuild allowlist must be an array');
}
return approvedEntries.map((approval) => {
for (const field of ['path', 'name', 'version', 'integrity', 'verifier']) {
if (typeof approval?.[field] !== 'string' || approval[field] === '') {
throw new Error(`native rebuild approval is missing ${field}`);
}
}
if (!SUPPORTED_VERIFIERS.has(approval.verifier)) {
throw new Error(`unsupported native rebuild verifier: ${approval.verifier}`);
}
if (!PACKAGE_NAME.test(approval.name)) {
throw new Error("invalid package name for native rebuild: " + approval.name);
}
if (packageNameFromPath(approval.path) !== approval.name) {
throw new Error("native rebuild name mismatch for " + approval.path);
}
const locked = lockfile.packages[approval.path];
if (!locked) throw new Error(`native rebuild path missing from lockfile: ${approval.path}`);
if (!locked.hasInstallScript) {
throw new Error(`native rebuild target has no install script: ${approval.path}`);
}
if (locked.version !== approval.version) {
throw new Error(`native rebuild version mismatch for ${approval.path}`);
}
if (locked.integrity !== approval.integrity) {
throw new Error(`native rebuild integrity mismatch for ${approval.path}`);
}
return { ...approval };
});
}
function run(command, args) {
const result = spawnSync(command, args, {
env: { ...process.env, npm_config_audit: 'false' },
stdio: 'inherit',
shell: false,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
}
}
/**
* `npm rebuild` に渡す引数。
*
* `--build-from-source` は、配布済みバイナリを取ってこさせず必ず手元で組ませる
* ための指定(取得物を 1 つ減らすので安全側)。CI の runner にコンパイラ・make・
* python3 が要るようになる代わりなので、消すときはそこまで見ること。
*
* `--` は、名前が `-` で始まっていても旗として解釈させないための区切り。名前は
* すでに PACKAGE_NAME で縛ってあるので二重の守りだが、縛りのほうが緩んだときに
* ここだけで止まる形にしておく。
*/
export function rebuildArgs(name) {
return ['rebuild', '--build-from-source', '--foreground-scripts', '--no-audit', '--', name];
}
export function verify(verifier) {
if (verifier === 'better-sqlite3-memory-db') {
run(process.execPath, [
'-e',
"const Database=require('better-sqlite3'); new Database(':memory:').close()",
]);
return;
}
throw new Error(`unsupported native rebuild verifier: ${verifier}`);
}
export async function main() {
const [lockfile, policy] = await Promise.all([
readFile(LOCKFILE_PATH, 'utf8').then(JSON.parse),
readFile(POLICY_PATH, 'utf8').then(JSON.parse),
]);
const approved = validateRebuildEntries(lockfile, policy.rebuild?.[LOCKFILE_PATH]);
for (const entry of approved) {
run('npm', rebuildArgs(entry.name));
verify(entry.verifier);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
+41
View File
@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises';
import { parse } from 'yaml';
import { describe, expect, it } from 'vitest';
const workflowPath = new URL('../../.gitea/workflows/ci.yml', import.meta.url);
describe('CI workflow semantic security policy', () => {
it('pins actions, minimizes permissions, and leaves no checkout credential', async () => {
const source = await readFile(workflowPath, 'utf8');
const workflow = parse(source);
expect(workflow.permissions).toEqual({ contents: 'read' });
expect(workflow.on).toHaveProperty('pull_request');
expect(source).not.toContain('${{ secrets.');
for (const job of Object.values(workflow.jobs)) {
expect(job.permissions ?? workflow.permissions).toEqual({ contents: 'read' });
for (const step of job.steps ?? []) {
if (!step.uses) continue;
expect(step.uses).toMatch(/^[^@\s]+@[a-f0-9]{40}$/);
expect(step.uses).not.toMatch(/(?:^|\/)cache(?:\/(?:restore|save))?@/);
expect(step.with ?? {}).not.toHaveProperty('cache');
if (step.uses.startsWith('actions/checkout@')) {
expect(step.with?.['persist-credentials']).toBe(false);
}
}
}
});
it('does not invoke repository package hooks before policy or native rebuild', async () => {
const source = await readFile(workflowPath, 'utf8');
expect(source).toContain(
'node scripts/security/dependency-policy.mjs package-lock.json ui/package-lock.json',
);
expect(source).toContain('node scripts/security/rebuild-native.mjs');
expect(source).not.toContain('npm run security:dependency-policy');
expect(source).not.toContain('npm run ci:rebuild-native');
});
});
@@ -0,0 +1,41 @@
// 起動に失敗したとき、その理由が画面に出ることを守るテスト (#1235)。
//
// 起動を断る判定は 3 行以上の本文で「なぜ止めたか」と「どう直すか」を書く。
// ところが致命的な例外は `src/main.ts` の最上位ハンドラが `err.stack` ごと
// 1 レコードで書き出すので、記録の並びは【本文】→【呼び出し位置の羅列】になる。
// `scripts/server.sh` が末尾 5 行しか出さないと、出るのは呼び出し位置だけで、
// 肝心の本文は画面から溢れる。直し方が書いてあるのに管理者には見えない。
//
// だからここで守るのは「行数の数字」ではなく、**本文が収まる幅が残っていること**。
// 末尾の行数を減らす変更は、ここで落ちる。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
/** 起動失敗の案内は最長で 4 行。加えてスタックの見出しと呼び出し位置が続く。 */
const MIN_TAIL_LINES = 20;
describe('起動失敗時の出力 (#1235)', () => {
it('server.sh の起動失敗は、案内の本文が収まる行数を出す', () => {
const sh = readFileSync(join(repoRoot, 'scripts', 'server.sh'), 'utf8');
const failureBlock = sh.slice(sh.indexOf('Server failed to start'));
const tail = /tail\s+(?:-n\s*)?-?(\d+)\s+"\$LOG_FILE"/.exec(failureBlock);
expect(tail).not.toBeNull();
expect(Number(tail![1])).toBeGreaterThanOrEqual(MIN_TAIL_LINES);
});
it('起動を断る本文とスタックの合計が、その行数に収まる', () => {
// 本文の行数は実物から数える。文面を伸ばしたときに、この歯止めが
// 一緒に見直されるようにするため。
const src = readFileSync(join(repoRoot, 'src', 'worker-bootstrap.ts'), 'utf8');
// throw new Error(`...`) のテンプレートリテラルに含まれる改行の数。
const refusals = src.match(/throw new Error\(\s*`[\s\S]*?\)\s*;/g) ?? [];
expect(refusals.length).toBeGreaterThan(0);
const longest = Math.max(...refusals.map((r) => (r.match(/\\n/g) ?? []).length + 1));
// スタックの見出し 1 行 + 呼び出し位置の余裕。
expect(longest + 10).toBeLessThanOrEqual(MIN_TAIL_LINES);
});
});
+14 -1
View File
@@ -113,7 +113,12 @@ do_start() {
echo " If a previous run was not stopped cleanly, stop it first to avoid port/DB conflicts."
fi
# The log captures stdout, which on a fresh non-loopback install includes the
# generated admin password (#1024). Keep it owner-only — the password file it
# is printed alongside is 0600, and a world-readable copy in logs/ would undo
# that. Applied every start so an existing log gets tightened too.
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE" && chmod 600 "$LOG_FILE"
echo "Checking runtime dependencies..."
"$SCRIPT_DIR/prepare.sh"
@@ -121,6 +126,11 @@ do_start() {
echo "Building..."
npm run build --silent 2>&1 | tail -1
if [[ "${AAO_MODE:-worker}" == "worker" ]]; then
echo "Running database migrations..."
node dist/scripts/migrate-db.js
fi
echo "Starting server on port $PORT..."
# AAO is a single binary with two modes (dist/main.js dispatches):
# AAO_MODE=worker (default) — full orchestrator (DB + bridge API + workers)
@@ -145,7 +155,10 @@ do_start() {
else
rm -f "$PID_FILE"
echo "Server failed to start. Check $LOG_FILE"
tail -5 "$LOG_FILE"
# 20 lines, not 5 (#1235): a fatal error is logged as one multi-line record
# whose stack trace lands *after* the explanation, so a short tail shows the
# call sites and cuts off the part that says how to fix it.
tail -20 "$LOG_FILE"
return 1
fi
}
+73 -16
View File
@@ -2,16 +2,17 @@
set -euo pipefail
echo "=== noVNC CAPTCHA 解決機能セットアップ ==="
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# 1. システム依存パッケージのインストール
echo ""
echo "[1/5] システムパッケージをインストール中..."
echo "[1/6] システムパッケージをインストール中..."
sudo apt-get update -qq
sudo apt-get install -y -qq x11vnc websockify xvfb
# 2. 確認
echo ""
echo "[2/5] インストール確認..."
echo "[2/6] インストール確認..."
for cmd in Xvfb x11vnc websockify; do
if which "$cmd" > /dev/null 2>&1; then
echo " ✓ $cmd: $(which "$cmd")"
@@ -23,21 +24,26 @@ done
# 3. @novnc/novnc npm パッケージ確認
echo ""
echo "[3/5] @novnc/novnc パッケージ確認..."
if node -e "require.resolve('@novnc/novnc/package.json')" 2>/dev/null; then
echo "[3/6] @novnc/novnc パッケージ確認..."
if (cd "$PROJECT_DIR" && node -e 'require.resolve("@novnc/novnc/package.json")') 2>/dev/null; then
echo " ✓ @novnc/novnc: インストール済み"
else
echo " → npm install @novnc/novnc ..."
npm install @novnc/novnc
echo " → lockfileを検査して安全に依存関係を復元中..."
(
cd "$PROJECT_DIR"
node scripts/security/dependency-policy.mjs package-lock.json ui/package-lock.json
npm ci --ignore-scripts --no-audit
node scripts/security/rebuild-native.mjs
)
fi
# 4. noVNC スタンドアロン (vnc.html を含む Web 配布物) を vendor/noVNC に配置
# npm の @novnc/novnc は lib のみで vnc.html を含まないため、
# Browser タブの iframe 用に GitHub から tarball を取得する。
echo ""
echo "[4/5] noVNC Web 配布物 (vendor/noVNC) 確認..."
NOVNC_VERSION="${NOVNC_VERSION:-1.6.0}"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "[4/6] noVNC Web 配布物 (vendor/noVNC) 確認..."
NOVNC_VERSION=1.6.0
NOVNC_SHA256=5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa
VENDOR_DIR="$PROJECT_DIR/vendor"
NOVNC_DIR="$VENDOR_DIR/noVNC"
if [ -f "$NOVNC_DIR/vnc.html" ]; then
@@ -45,17 +51,56 @@ if [ -f "$NOVNC_DIR/vnc.html" ]; then
else
echo " → noVNC v$NOVNC_VERSION を取得中..."
mkdir -p "$VENDOR_DIR"
TMP_TGZ=""
TMP_NOVNC_DIR=""
NOVNC_BACKUP_DIR=""
cleanup_novnc() {
exit_status=$?
trap - EXIT INT TERM
if [ -n "$NOVNC_BACKUP_DIR" ] && [ ! -e "$NOVNC_DIR" ] && [ -e "$NOVNC_BACKUP_DIR" ]; then
mv "$NOVNC_BACKUP_DIR" "$NOVNC_DIR"
fi
if [ -n "$NOVNC_BACKUP_DIR" ] && [ -e "$NOVNC_BACKUP_DIR" ]; then
rm -rf "$NOVNC_BACKUP_DIR"
fi
[ -z "$TMP_TGZ" ] || rm -f "$TMP_TGZ"
[ -z "$TMP_NOVNC_DIR" ] || rm -rf "$TMP_NOVNC_DIR"
exit "$exit_status"
}
trap cleanup_novnc EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
TMP_TGZ="$(mktemp -t novnc.XXXXXX.tar.gz)"
trap 'rm -f "$TMP_TGZ"' EXIT
TMP_NOVNC_DIR="$(mktemp -d "$VENDOR_DIR/.noVNC.XXXXXX")"
if ! curl -fSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" -o "$TMP_TGZ"; then
echo " ✗ noVNC tarball の取得に失敗しました" >&2
exit 1
fi
rm -rf "$NOVNC_DIR"
mkdir -p "$NOVNC_DIR"
tar -xzf "$TMP_TGZ" -C "$NOVNC_DIR" --strip-components=1
if ! printf '%s %s\n' "$NOVNC_SHA256" "$TMP_TGZ" | sha256sum -c -; then
echo " ✗ noVNC tarball のSHA-256検証に失敗しました" >&2
exit 1
fi
tar -xzf "$TMP_TGZ" -C "$TMP_NOVNC_DIR" --strip-components=1
if [ ! -f "$TMP_NOVNC_DIR/vnc.html" ]; then
echo " ✗ vnc.html が見つかりません。配置に失敗しました" >&2
exit 1
fi
NOVNC_BACKUP_DIR=""
if [ -e "$NOVNC_DIR" ]; then
NOVNC_BACKUP_DIR="$(mktemp -d "$VENDOR_DIR/.noVNC.backup.XXXXXX")"
rmdir "$NOVNC_BACKUP_DIR"
mv "$NOVNC_DIR" "$NOVNC_BACKUP_DIR"
fi
if ! mv "$TMP_NOVNC_DIR" "$NOVNC_DIR"; then
if [ -n "$NOVNC_BACKUP_DIR" ]; then
mv "$NOVNC_BACKUP_DIR" "$NOVNC_DIR"
fi
echo " ✗ noVNC vendorの置換に失敗しました" >&2
exit 1
fi
[ -z "$NOVNC_BACKUP_DIR" ] || rm -rf "$NOVNC_BACKUP_DIR"
rm -f "$TMP_TGZ"
trap - EXIT
trap - EXIT INT TERM
if [ -f "$NOVNC_DIR/vnc.html" ]; then
echo " ✓ vendor/noVNC/vnc.html: 配置完了"
else
@@ -64,9 +109,21 @@ else
fi
fi
# 5. config.yaml に display_mode 設定を追加(まだなければ)
# 5. ファイル選択の画面を禁じる管理ポリシー
# noVNC の画面は人がブラウザをそのまま触れる。ファイルを選ぶボタンから
# サーバー上のファイルを持ち出せてしまうので、選択画面そのものを禁じる (#1148)。
echo ""
echo "[5/5] config.yaml 確認..."
echo "[5/6] ファイル選択の画面を禁じるポリシーを配置..."
if bash "$(dirname "$0")/install-browser-policy.sh"; then
echo " ✓ 配置しました"
else
echo " ⚠ 配置に失敗しました。noVNC の画面からファイル選択画面を開けてしまいます。" >&2
echo " 後から 'sudo scripts/install-browser-policy.sh' で入れてください。" >&2
fi
# 6. config.yaml に display_mode 設定を追加(まだなければ)
echo ""
echo "[6/6] config.yaml 確認..."
CONFIG="$(cd "$(dirname "$0")/.." && pwd)/config.yaml"
if [ -f "$CONFIG" ]; then
if grep -qE "display_mode|captcha_solve" "$CONFIG"; then
+48
View File
@@ -0,0 +1,48 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
const setupScript = fs.readFileSync(
path.join(root, 'scripts/setup-novnc.sh'),
'utf8',
);
function readPins(source) {
return {
version: source.match(/\bNOVNC_VERSION(?:=|:-)([0-9.]+)/)?.[1],
sha256: source.match(/\bNOVNC_SHA256(?:=|:-)([a-f0-9]{64})/)?.[1],
};
}
describe('noVNC bootstrap integrity', () => {
it('keeps Dockerfile and setup script pins identical', () => {
expect(readPins(setupScript)).toEqual(readPins(dockerfile));
expect(readPins(setupScript).sha256).toMatch(/^[a-f0-9]{64}$/);
});
it('checks SHA-256 before replacing the existing vendor directory', () => {
const checksum = setupScript.indexOf('sha256sum -c -');
const replacement = setupScript.indexOf('mv "$TMP_NOVNC_DIR" "$NOVNC_DIR"');
expect(checksum).toBeGreaterThan(-1);
expect(replacement).toBeGreaterThan(checksum);
});
it('pins the npm fallback and disables lifecycle scripts in the project', () => {
const projectDir = setupScript.indexOf('PROJECT_DIR=');
const npmCheck = setupScript.indexOf('require.resolve("@novnc/novnc/package.json")');
const policy = setupScript.indexOf("node scripts/security/dependency-policy.mjs");
const npmInstall = setupScript.indexOf("npm ci --ignore-scripts --no-audit");
const rebuild = setupScript.indexOf("node scripts/security/rebuild-native.mjs");
expect(projectDir).toBeGreaterThan(-1);
expect(npmCheck).toBeGreaterThan(projectDir);
expect(policy).toBeGreaterThan(npmCheck);
expect(npmInstall).toBeGreaterThan(policy);
expect(rebuild).toBeGreaterThan(npmInstall);
expect(setupScript).not.toContain('npm install @novnc/novnc');
});
});
+453
View File
@@ -0,0 +1,453 @@
{
"_comment": [
"Known uses of a Tailwind opacity modifier on a color that has no alpha slot",
"(see scripts/check-tailwind-opacity.mjs). These produce no CSS at all.",
"This file exists so the check fails on NEW ones only. Do not add to it by",
"hand: fix the class, then run `npm run check:tailwind-opacity -- --update`."
],
"total": 106,
"files": 52,
"classes": 46,
"entries": [
{
"file": "ui/src/components/chat/AskUserCard.tsx",
"class": "bg-accent/10",
"count": 2
},
{
"file": "ui/src/components/chat/AskUserCard.tsx",
"class": "dark:bg-slate-900/30",
"count": 1
},
{
"file": "ui/src/components/chat/AskUserCard.tsx",
"class": "dark:bg-slate-900/40",
"count": 1
},
{
"file": "ui/src/components/chat/ChatComposerPanel.tsx",
"class": "bg-canvas/90",
"count": 1
},
{
"file": "ui/src/components/chat/ChatMessage.tsx",
"class": "dark:bg-slate-900/40",
"count": 1
},
{
"file": "ui/src/components/chat/ChatPane.tsx",
"class": "bg-canvas/80",
"count": 1
},
{
"file": "ui/src/components/chat/DelegateLiveConsole.tsx",
"class": "bg-canvas/60",
"count": 1
},
{
"file": "ui/src/components/chat/DelegateLiveConsole.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/chat/MovementGroup.tsx",
"class": "hover:bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/chat/MovementMap.tsx",
"class": "bg-accent/10",
"count": 2
},
{
"file": "ui/src/components/chat/MovementMap.tsx",
"class": "bg-canvas/80",
"count": 1
},
{
"file": "ui/src/components/chat/MovementMap.tsx",
"class": "bg-canvas/90",
"count": 1
},
{
"file": "ui/src/components/chat/MovementMap.tsx",
"class": "bg-slate-400/25",
"count": 2
},
{
"file": "ui/src/components/chat/MovementMap.tsx",
"class": "border-slate-400/60",
"count": 1
},
{
"file": "ui/src/components/chat/MovementMap.tsx",
"class": "dark:border-slate-700/60",
"count": 1
},
{
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
"class": "bg-slate-50/80",
"count": 1
},
{
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
"class": "dark:bg-slate-500/15",
"count": 1
},
{
"file": "ui/src/components/chat/shared/ChatPresentation.tsx",
"class": "dark:bg-slate-900/30",
"count": 1
},
{
"file": "ui/src/components/chat/ToolCallsSection.tsx",
"class": "bg-surface/70",
"count": 2
},
{
"file": "ui/src/components/chat/ToolCallsSection.tsx",
"class": "hover:bg-surface/50",
"count": 2
},
{
"file": "ui/src/components/create/CreateTaskDialogAdvancedSection.tsx",
"class": "bg-slate-50/50",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "bg-slate-50/60",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "border-accent/30",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "border-accent/40",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "dark:bg-slate-800/40",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "dark:bg-slate-900/50",
"count": 1
},
{
"file": "ui/src/components/create/PromptCoachPanel.tsx",
"class": "hover:bg-accent/5",
"count": 1
},
{
"file": "ui/src/components/dashboard/WorkerStatusWidget.tsx",
"class": "bg-accent/10",
"count": 1
},
{
"file": "ui/src/components/detail/ContinueWithPieceDialog.tsx",
"class": "focus:ring-accent/30",
"count": 2
},
{
"file": "ui/src/components/detail/DetailPanel.tsx",
"class": "border-hairline/70",
"count": 1
},
{
"file": "ui/src/components/detail/tabs/console/ConsoleTabStrip.tsx",
"class": "bg-surface/40",
"count": 1
},
{
"file": "ui/src/components/detail/tabs/console/ConsoleTabStrip.tsx",
"class": "border-accent/60",
"count": 1
},
{
"file": "ui/src/components/detail/tabs/ConsoleTab.tsx",
"class": "bg-surface/10",
"count": 2
},
{
"file": "ui/src/components/files/DictionaryPopover.tsx",
"class": "hover:bg-accent-soft/80",
"count": 1
},
{
"file": "ui/src/components/files/FileDetailList.tsx",
"class": "border-hairline/60",
"count": 1
},
{
"file": "ui/src/components/files/FileHistoryModal.tsx",
"class": "bg-accent/10",
"count": 1
},
{
"file": "ui/src/components/files/FileSectionTabs.tsx",
"class": "border-accent/60",
"count": 1
},
{
"file": "ui/src/components/files/FileToolbar.tsx",
"class": "bg-canvas/85",
"count": 1
},
{
"file": "ui/src/components/files/MoveTargetDialog.tsx",
"class": "divide-hairline/60",
"count": 1
},
{
"file": "ui/src/components/list/FilterBar.tsx",
"class": "border-accent/60",
"count": 2
},
{
"file": "ui/src/components/list/RailPanel.tsx",
"class": "bg-accent/10",
"count": 1
},
{
"file": "ui/src/components/list/RailPanel.tsx",
"class": "ring-accent/40",
"count": 1
},
{
"file": "ui/src/components/notifications/NotificationRow.tsx",
"class": "bg-accent/5",
"count": 1
},
{
"file": "ui/src/components/settings/GatewayKeyUsagePanel.tsx",
"class": "bg-accent/70",
"count": 1
},
{
"file": "ui/src/components/settings/MemoryLearningForm.tsx",
"class": "hover:bg-surface/60",
"count": 1
},
{
"file": "ui/src/components/settings/OrgsForm.tsx",
"class": "border-accent/60",
"count": 1
},
{
"file": "ui/src/components/settings/SkillsForm.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/settings/SkillsForm.tsx",
"class": "border-accent/30",
"count": 2
},
{
"file": "ui/src/components/settings/SshAuditLog.tsx",
"class": "bg-surface/60",
"count": 1
},
{
"file": "ui/src/components/settings/SshAuditLog.tsx",
"class": "hover:bg-surface/40",
"count": 1
},
{
"file": "ui/src/components/settings/SshGlobalConnectionsForm.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/settings/SshGlobalConnectionsForm.tsx",
"class": "border-accent/30",
"count": 1
},
{
"file": "ui/src/components/settings/SshGlobalConnectionsForm.tsx",
"class": "border-accent/40",
"count": 1
},
{
"file": "ui/src/components/settings/SshGrantsForm.tsx",
"class": "bg-surface/40",
"count": 1
},
{
"file": "ui/src/components/settings/SshGrantsForm.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/settings/SshGrantsForm.tsx",
"class": "border-accent/40",
"count": 1
},
{
"file": "ui/src/components/settings/SshMasterKeyRotationForm.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/setup/SetupWizard.tsx",
"class": "focus:ring-accent/40",
"count": 1
},
{
"file": "ui/src/components/spaces/ChatDetailSplit.tsx",
"class": "group-hover:bg-slate-500/40",
"count": 1
},
{
"file": "ui/src/components/spaces/ChatDetailSplit.tsx",
"class": "hover:bg-slate-300/60",
"count": 1
},
{
"file": "ui/src/components/spaces/PaneResizeHandle.tsx",
"class": "bg-slate-400/60",
"count": 1
},
{
"file": "ui/src/components/spaces/PaneResizeHandle.tsx",
"class": "focus-visible:bg-slate-400/60",
"count": 1
},
{
"file": "ui/src/components/spaces/PaneResizeHandle.tsx",
"class": "hover:bg-slate-300/60",
"count": 1
},
{
"file": "ui/src/components/spaces/PythonPackagesPanel.tsx",
"class": "bg-surface/40",
"count": 2
},
{
"file": "ui/src/components/spaces/SpaceChatRail.tsx",
"class": "dark:hover:bg-slate-700/40",
"count": 1
},
{
"file": "ui/src/components/spaces/SpaceMembersPanel.tsx",
"class": "bg-surface/40",
"count": 1
},
{
"file": "ui/src/components/spaces/SpaceMembersPanel.tsx",
"class": "bg-surface/50",
"count": 2
},
{
"file": "ui/src/components/spaces/SpaceMembersPanel.tsx",
"class": "border-accent/30",
"count": 1
},
{
"file": "ui/src/components/spaces/SpaceToolSettings.tsx",
"class": "bg-surface/40",
"count": 2
},
{
"file": "ui/src/components/spaces/SpaceWebhookSettings.tsx",
"class": "bg-surface/40",
"count": 2
},
{
"file": "ui/src/components/userfolder/AgentsMdPanel.tsx",
"class": "bg-surface-2/30",
"count": 1
},
{
"file": "ui/src/components/userfolder/McpPanel.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/userfolder/McpPanel.tsx",
"class": "border-accent/30",
"count": 1
},
{
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
"class": "bg-accent/10",
"count": 1
},
{
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
"class": "bg-surface/40",
"count": 1
},
{
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
"class": "bg-surface/60",
"count": 1
},
{
"file": "ui/src/components/userfolder/MemoryPanel.tsx",
"class": "hover:bg-surface/60",
"count": 1
},
{
"file": "ui/src/components/userfolder/SshConnectionForm.tsx",
"class": "bg-surface/40",
"count": 1
},
{
"file": "ui/src/components/userfolder/SshConnectionsPanel.tsx",
"class": "border-accent/30",
"count": 1
},
{
"file": "ui/src/components/userfolder/SshConnectionsPanel.tsx",
"class": "border-accent/40",
"count": 1
},
{
"file": "ui/src/components/userfolder/SshHostKeyDialog.tsx",
"class": "bg-surface/50",
"count": 1
},
{
"file": "ui/src/components/userfolder/SshPublicKeyDialog.tsx",
"class": "bg-surface/30",
"count": 1
},
{
"file": "ui/src/components/userfolder/SshPublicKeyDialog.tsx",
"class": "bg-surface/40",
"count": 2
},
{
"file": "ui/src/lib/workspaceDirs.ts",
"class": "dark:bg-slate-500/15",
"count": 1
},
{
"file": "ui/src/lib/workspaceDirs.ts",
"class": "dark:bg-slate-600/30",
"count": 1
},
{
"file": "ui/src/pages/AdminEventLogPage.tsx",
"class": "hover:bg-surface-2/60",
"count": 1
},
{
"file": "ui/src/pages/SchedulesPage.tsx",
"class": "border-accent/60",
"count": 2
},
{
"file": "ui/src/pages/UsersPage.tsx",
"class": "border-accent/60",
"count": 4
}
]
}
+445
View File
@@ -0,0 +1,445 @@
// UI の型検査が「関所」として残り続けることを守るテスト (#1001)。
//
// issue #1001 の原因は個別の型エラーではなく、**UI 側に型検査の関所が無かった**
// こと。vite build は型を見ず、vitest は esbuild でトランスパイルするだけなので、
// 誰も止めないまま 12 ファイル 37 件が溜まった
// (origin/main = 34aa673e を素の状態でチェックアウトして `cd ui && npx tsc
// --noEmit` した実測。issue 本文の 30 件は起票時点の値)。
//
// 型エラーを潰すだけでは同じことが必ず再発する。だからこのテストが守るのは
// 「今エラーが 0 件であること」ではなく、**検査が実行される経路が残っていること**。
// 関所を外す変更は、ここで落ちる。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (p: string) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
/**
* ES2022 で標準ライブラリに入ったメソッドだけを使う検査用の小片 (#1314)。
* ui/tsconfig.json の lib がこれらを含んでいれば通り、含んでいなければ TS2550。
*/
const ES2022_PROBE_SOURCE = [
'const numbers = [1, 2, 3];',
'export const last = numbers.at(-1);',
"export const head = 'abc'.at(0);",
].join('\n');
/**
* ReadableStream を直接 for await で回す小片 (#1314)。
*
* この構文を成立させているのは lib.dom.asynciterable の 1 枚だけ。lib を明示した
* ときに書き起こし損ねやすい枚数で、落とすと「触っていないファイルが落ちる」が
* #1314 とは逆向きに起きる(今度は原因がこちらの設定変更になる)。
*/
const DOM_ASYNC_ITERABLE_PROBE_SOURCE = [
'export async function drain(stream: ReadableStream<Uint8Array>): Promise<number> {',
' let total = 0;',
' for await (const chunk of stream) total += chunk.byteLength;',
' return total;',
'}',
].join('\n');
/**
* ブラウザに無いもの——Node の型が漏れているかを見るための小片 3 つ (#1316)。
*
* 親ディレクトリの node_modules/@types が画面側の検査に混ざっていた頃は、3 つとも
* 診断ゼロで通っていた。とくに Buffer はブラウザに存在しないので、検査を素通りした
* まま出荷され、実行時に壊れる。1 つずつ分けてあるのは、どれが復活したかを
* 失敗メッセージだけで見分けられるようにするため。
*/
const NODE_TIMER_PROBE_SOURCE = 'export const handle: NodeJS.Timeout = setTimeout(() => {}, 0);';
const NODE_PROCESS_PROBE_SOURCE = 'export const flag = process.env.SOME_FLAG;';
const NODE_BUFFER_PROBE_SOURCE = "export const bytes = Buffer.from('hi');";
/** 上の 3 つの対照。ブラウザにあるものは通る、を同じ道具立てで確かめる。 */
const BROWSER_PROBE_SOURCE = [
'export const timer = window.setTimeout(() => {}, 0);',
'export const text = new TextDecoder().decode(new Uint8Array([104, 105]));',
].join('\n');
/**
* tsc が実際に組むファイル一覧を返す。
*
* 設定の字面(include / exclude / strict)を見るだけでは穴が残る。include は
* "src" のままで exclude に "src/**\/*.test.tsx" を足すと、227 本のテストが
* 黙って検査対象から外れる。解決済みの一覧を見れば、どちらの経路で抜いても落ちる。
*/
function resolveUiProgram(configName: string): string[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
expect(parsed.options.strict).toBe(true);
return parsed.fileNames;
}
/**
* ui/tsconfig.json の compilerOptions だけで小さなコードを検査し、診断を返す。
*
* types を空にしてあるのが肝。TypeScript は node_modules/@types を親ディレクトリへ
* 遡って自動で取り込むので、既定のままだとリポジトリ直下の @types/* が画面側の
* 検査に混ざる。空にすると「画面側の設定だけで成り立っているか」を見られる。
*/
function diagnoseWithUiOptions(
source: string,
overrides: ts.CompilerOptions = {},
): readonly ts.Diagnostic[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
const options: ts.CompilerOptions = {
...parsed.options,
...overrides,
types: [],
noEmit: true,
noResolve: true,
};
const probePath = join(uiDir, '__lib-probe__.ts');
const host = ts.createCompilerHost(options, true);
const readOriginal = host.readFile.bind(host);
const getOriginal = host.getSourceFile.bind(host);
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
f === probePath
? ts.createSourceFile(f, source, langVersion, true)
: getOriginal(f, langVersion, onError, shouldCreate);
const program = ts.createProgram([probePath], options, host);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
}
/**
* 指定した設定ファイルの compilerOptions を**そのまま**使って小片を検査する (#1316)。
*
* 上の diagnoseWithUiOptions との違いは 2 点あり、どちらも外せない。
*
* 1. types を上書きしない。あちらは types: [] を強制するので、tsconfig.json が
* types を書いていようがいまいが同じ結果になる——つまり #1316 の検体を
* あちらに載せると、設定を直す前から通ってしまう(空振り)。ここは設定の
* 実物を見る必要があるので、上書きしない。
* 2. noResolve を立てない。noResolve は types で名指しした型定義の読み込みまで
* 止めてしまい、types: ["node"] を書いても Node の型が入らなくなる(実測)。
* それでは tsconfig.typecheck.json 側の検査が意味を失う。
*
* 小片は ui/ 直下に置いたことにする。@types の探索は置き場所から始まるので、
* 本物のファイルと同じ条件になる。
*/
function diagnoseWithConfigAsWritten(
configName: string,
source: string,
overrides: ts.CompilerOptions = {},
): readonly ts.Diagnostic[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
const options: ts.CompilerOptions = { ...parsed.options, ...overrides, noEmit: true };
const probePath = join(uiDir, '__node-probe__.ts');
const host = ts.createCompilerHost(options, true);
const readOriginal = host.readFile.bind(host);
const getOriginal = host.getSourceFile.bind(host);
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
f === probePath
? ts.createSourceFile(f, source, langVersion, true)
: getOriginal(f, langVersion, onError, shouldCreate);
const program = ts.createProgram([probePath], options, host);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
}
/** ディスク上に実在する ui/src の TypeScript ファイル一覧(設定の字面を通さずに数える)。 */
function sourceFilesOnDisk(): string[] {
return ts.sys.readDirectory(join(repoRoot, 'ui', 'src'), ['.ts', '.tsx'], ['node_modules'], undefined);
}
/**
* 設定ファイルを**本物のプログラムとして組み**、読み込まれたファイルを全部返す (#1316)。
*
* resolveUiProgram が返すのは根っこ(include / exclude で選ばれたファイル)だけで、
* そこから import や参照でたぐり寄せられた .d.ts は含まれない。types: [] が守るのは
* まさにその「たぐり寄せ」の側なので、根っこだけ見ていても関所の状態は分からない。
*
* 第 5 引数に設定ファイルのパスを渡すのが肝。渡さないと typeRoots の探索が
* プロセスの作業ディレクトリ(=リポジトリ直下)から始まり、@types/node が
* ui/node_modules ではなく直下から拾われる。本物の `cd ui && tsc` と条件が変わって
* しまい、ui/package.json の宣言が効いているかを見られなくなる(実測)。
*/
function realProgramFiles(configName: string): string[] {
const uiDir = join(repoRoot, 'ui');
const configPath = join(uiDir, configName);
const raw = ts.readConfigFile(configPath, ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir, undefined, configPath);
expect(parsed.errors).toEqual([]);
const program = ts.createProgram(parsed.fileNames, { ...parsed.options, noEmit: true });
return program.getSourceFiles().map((f) => f.fileName);
}
/** 上の一覧のうち、Node の型定義(@types/node 配下)だけを取り出す。 */
function nodeTypeFiles(files: readonly string[]): string[] {
return files.filter((f) => /[\\/]@types[\\/]node[\\/]/.test(f));
}
describe('UI 型検査のゲート (#1001)', () => {
it('ui のビルドは型検査を通ってからバンドルする', () => {
const build: string = readJson('ui/package.json').scripts.build;
expect(build).toContain('tsc --noEmit');
// 順序も守る: バンドルが先だと、型エラーでも成果物が出てしまう。
expect(build.indexOf('tsc --noEmit')).toBeLessThan(build.indexOf('vite build'));
});
it('型検査だけを単体で呼べる', () => {
// CI と開発者が「バンドルせずに型だけ見る」ために必要。
expect(readJson('ui/package.json').scripts.typecheck).toContain('tsc -p tsconfig.typecheck.json');
expect(readJson('package.json').scripts['typecheck:ui']).toContain('ui run typecheck');
});
it('CI に独立した型検査ステップがある(コメントアウトも不可)', () => {
// build に含まれてはいるが、独立ステップにしておくと「型で落ちた」のか
// 「バンドルで落ちた」のかがログを読まずに分かる。
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
const live = ci.split('\n').filter(line => !line.trim().startsWith('#'));
expect(live.some(line => line.includes('npm run typecheck:ui'))).toBe(true);
// ステップ単位で失敗を無視されると、赤いはずの CI が緑になる。
expect(live.some(line => line.includes('continue-on-error'))).toBe(false);
});
it('型検査の失敗を握り潰していない', () => {
// `tsc --noEmit || true` と書けば、上の 3 つはすべて通ったまま関所が
// 無力化する。実行されていることと、落ちること(=失敗が伝播すること)は別。
//
// 見るのは呼び出し連鎖の全段。ui のスクリプトだけを見ていると、CI の
// `run:` かルートの中継スクリプトに 1 つ足すだけで素通りする(実測で確認)。
const swallowed = /\|\|\s*(true|:|exit\s+0)|;\s*(true|exit\s+0)\s*$/;
const uiScripts = readJson('ui/package.json').scripts;
const rootScripts = readJson('package.json').scripts;
for (const script of [uiScripts.build, uiScripts.typecheck, rootScripts['typecheck:ui']]) {
expect(script).not.toMatch(swallowed);
expect(script).not.toContain('--noEmit false');
}
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
for (const line of ci.split('\n')) {
if (line.trim().startsWith('#') || !line.includes('typecheck:ui')) continue;
expect(line).not.toMatch(swallowed);
}
});
it('ui の TypeScript は、テストも E2E も設定ファイルも検査対象に入っている', () => {
// テスト側の型ずれ(実装のプロパティ追加にモックが追随していない等)は
// 実装と現実がずれ始めた合図。検査対象から外すと、その合図が消える。
//
// include / strict の形だけを見ると素通りする穴がある: include は "src" の
// ままで exclude に "src/**/*.test.tsx" を足すと、227 本のテストが黙って
// 検査対象から外れる。だから設定の字面ではなく、tsc が実際に組む
// ファイル一覧を見る(exclude であれ include であれ、抜けたら落ちる)。
const files = resolveUiProgram('tsconfig.typecheck.json');
const checked = files.filter((f) => /\.test\.tsx?$/.test(f));
expect(checked.length).toBeGreaterThan(0);
// 実装コードも当然入っていること(テストだけ検査しても意味がない)。
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
// E2E と ui/ 直下の設定ファイル。ここが抜けていたのが元の状態で、
// 「UI に関所が無い」という本件の主題からすると範囲が中途半端だった。
expect(files.some((f) => /\/e2e\/.*\.spec\.ts$/.test(f))).toBe(true);
expect(files.some((f) => /\/e2e-auth\/.*\.spec\.ts$/.test(f))).toBe(true);
expect(files.some((f) => /\/ui\/playwright\.config\.ts$/.test(f))).toBe(true);
});
it('ビルド用の設定は出荷物だけを見る(製品ビルドを E2E とテストに依存させない)', () => {
// build は Dockerfile の npm run build:ui から呼ばれる製品ビルドの経路。
// ここに E2E を混ぜると、製品イメージのビルドが @playwright/test などの
// devDependency に依存する。検査の網は広く、ビルドの依存は狭く。
//
// テストとテスト基盤 (src/test/) も外してある (#1316)。この設定は types: [] で
// Node の型を断っており、node:fs や global を正当に使う src のテスト (15 本) とは
// 両立しない。src/test/ を外す理由はもう 1 つあって、dom-setup.ts が読む vitest は
// ui/package.json に書かれておらず、リポジトリ直下から解決されていた——直下の
// node_modules から入るファイルが 109 本から 60 本 (TypeScript の標準ライブラリ
// だけ) に減る。「外したぶんが検査されなくなる」抜け穴は下の 1 件が塞ぐ。
const files = resolveUiProgram('tsconfig.json');
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
expect(files.filter((f) => /\.test\.tsx?$/.test(f))).toEqual([]);
expect(files.filter((f) => /\/src\/test\//.test(f))).toEqual([]);
expect(files.some((f) => /\/e2e(-auth)?\//.test(f))).toBe(false);
});
it('ui/src のファイルは 1 本残らず型検査用の設定に入っている (#1316)', () => {
// #1001 の元の見張りは「ビルド用の設定にテストが 1 本以上入っている」を見て
// いた。#1316 でビルド用からテストとテスト基盤を外したので、その担保をこちらへ
// 移す。数ではなくディスク上の実ファイルとの差分で見るぶん、元より強い——
// exclude を 1 行足して 1 ディレクトリ丸ごと外す、といった抜き方でも落ちるし、
// ビルド用から今後さらに何かを外しても、こちらに入っていなければ落ちる。
const onDisk = sourceFilesOnDisk();
expect(onDisk.length).toBeGreaterThan(0);
const checked = new Set(resolveUiProgram('tsconfig.typecheck.json'));
expect(onDisk.filter((f) => !checked.has(f))).toEqual([]);
});
});
describe('UI の標準ライブラリ設定 (#1314)', () => {
it('画面側の設定だけで ES2022 のメソッドが解決できる', () => {
// 親ディレクトリの @types を一切使わない条件で at() が引ける、が合格条件。
// lib の明示を消すと target (ES2020) から標準ライブラリが決まるので、ここが
// TS2550 で落ちる。
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE);
const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '));
expect(messages).toEqual([]);
});
it('lib を ES2020 相当に戻すと、この検査は実際に落ちる', () => {
// 上の検査が「何を書いても通る空振り」でないことの担保。ここが通らなくなったら
// 検査そのものが壊れている(親の @types に頼る経路が復活した等)。
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE, {
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
});
expect(diagnostics.map((d) => d.code)).toContain(2550);
});
it('ReadableStream を直接 for await で回せる(DOM.AsyncIterable が効いている)', () => {
// 直す前の暗黙値 lib.es2020.full は 6 枚を参照していた。lib を手で書き起こす
// ときに落ちやすいのがこの 1 枚で、落ちても既存コードは無傷のまま通る——
// 画面側のストリーム処理は getReader() 経由で、for await している 3 か所は
// 相手が非同期ジェネレータだから。次に誰かが本文を直接 for await した瞬間に
// 初めて落ちる。その「次の人」の代わりに、ここで先に踏んでおく。
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it('DOM.AsyncIterable を外すと、この検査は実際に落ちる', () => {
// 空振りの担保。1 枚だけ抜いた対照で、他は本物の設定のまま。
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE, {
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
});
expect(diagnostics.length).toBeGreaterThan(0);
});
it('lib は tsconfig に明示されている(target 任せにしない)', () => {
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
const lib: unknown = raw.config?.compilerOptions?.lib;
expect(Array.isArray(lib)).toBe(true);
const entries = (lib as string[]).map((l) => l.toLowerCase());
expect(entries).toContain('dom');
expect(entries).toContain('dom.asynciterable');
});
});
describe('画面側に Node の型を混ぜない (#1316)', () => {
// 直す前は 3 つとも診断ゼロで通っていた。原因は個別の型エラーではなく、
// TypeScript が node_modules/@types を親ディレクトリへ遡って自動で取り込む
// 性質——ui/package.json は Node の型定義を依存に持っていないのに、リポジトリ
// 直下の @types/node がそっくり画面側の検査に入っていた。
//
// 関所は ui/tsconfig.json 側にしか置けない。ui/tsconfig.typecheck.json の側は
// e2e が better-sqlite3 を読み、その .d.ts の `/// <reference types="node" />`
// が Node の型をプログラム全体へ呼び戻すので、types をどう書いても src で
// Buffer が通ってしまう(--explainFiles で確認)。
const buildProbes: ReadonlyArray<readonly [string, string, number]> = [
['タイマーの戻り値に NodeJS.Timeout を当てる', NODE_TIMER_PROBE_SOURCE, 2503],
['環境変数を読む', NODE_PROCESS_PROBE_SOURCE, 2591],
['バッファを作る', NODE_BUFFER_PROBE_SOURCE, 2591],
];
it.each(buildProbes)('出荷物の設定では落ちる: %s', (_name, source, code) => {
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', source);
expect(diagnostics.map((d) => d.code)).toContain(code);
});
it('出荷物の設定でも、ブラウザにあるものは通る', () => {
// 上の 3 件が「何を書いても落ちる」空振りでないことの対照。同じ道具立てで、
// window.setTimeout と TextDecoder は診断ゼロであること。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', BROWSER_PROBE_SOURCE);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it.each(buildProbes)('型検査用の設定では通る: %s', (_name, source) => {
// vite.config.ts と playwright の設定、e2e、src のテストはどれも Node の上で
// 動く。こちらまで断つと、それらが落ちる。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it.each(buildProbes)('型検査用の設定から types を空にすると、実際に落ちる: %s', (_name, source, code) => {
// 上の 3 件の空振り担保。types: ["node"] の 1 行が効いていることを見る。
//
// ここを本物の tsc(npx tsc -p tsconfig.typecheck.json)で確かめようとすると
// 空振りする: types を消しても、better-sqlite3 の三連スラッシュ参照が Node の
// 型を呼び戻すので通ってしまう。小片 1 つだけのプログラムなら、Node の型の
// 出どころは types しかない。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source, { types: [] });
expect(diagnostics.map((d) => d.code)).toContain(code);
});
it('出荷物の本物のプログラムに、Node の型定義が 1 本も入っていない (#1316)', () => {
// ここが**関所の本体**。上の 3 件は小片 1 本だけの小さなプログラムを組んで
// いるので、実質「設定の字面に types: [] と書いてあるか」しか見ていない。
//
// types: [] が止めるのは自動取り込みだけで、プログラム内のどれかの .d.ts が
// `/// <reference types="node" />` を書いていればそこから Node の型が入る。
// 実際に ui/src へその 1 行を書いたファイルを 1 本置くと、`cd ui && npx tsc
// --noEmit` は Buffer を診断ゼロで通し、字面を見る見張りは全部緑のままだった
// (実測)。しかも実在の経路がある: ui/src の .d.ts がビルドツールの入口を
// 参照していて、そのパッケージは別の場所ですでに Node を参照している。版が
// 上がって入口側が Node を引いた瞬間、関所は黙って開く。
//
// だから本物のプログラムを組んで、読み込まれた @types/node のファイル数を見る。
// 参照が 1 本入るだけで 0 → 63 に跳ねるので、差ははっきり出る(実測)。
const leaked = nodeTypeFiles(realProgramFiles('tsconfig.json'));
expect(leaked).toEqual([]);
});
it('型検査用の本物のプログラムには Node の型定義が入っている(上の見張りの対照)', () => {
// 上の 1 件が「どう組んでも 0 本になる空振り」でないことの担保。同じ道具立てで
// types: ["node"] の側を組むと、Node の型定義が実際に読み込まれる。
//
// 出どころも見る。ui/package.json の devDependencies に @types/node を足した
// 意味は、参照先を ui 自身の node_modules に固定すること。リポジトリ直下から
// 拾われていたら宣言は飾りなので、そこまで含めて落とす。
const loaded = nodeTypeFiles(realProgramFiles('tsconfig.typecheck.json'));
expect(loaded.length).toBeGreaterThan(0);
const uiOwned = join(repoRoot, 'ui', 'node_modules');
expect(loaded.filter((f) => !f.startsWith(uiOwned))).toEqual([]);
});
it('親の型定義の自動取り込みは、設定に書いて断ってある', () => {
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
const uiDir = join(repoRoot, 'ui');
const build = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
expect(build.config?.compilerOptions?.types).toEqual([]);
const typecheck = ts.readConfigFile(join(uiDir, 'tsconfig.typecheck.json'), ts.sys.readFile);
expect(typecheck.config?.compilerOptions?.types).toEqual(['node']);
});
it('Node の型定義は ui の依存として宣言され、直下と同じ版の範囲を書いている', () => {
// 宣言しないと、参照先はリポジトリ直下の node_modules を運任せに拾うことに戻る。
//
// ここが見ているのは**範囲の文字列が一致していること**だけで、実際に入る版まで
// は見ていない。固定ファイルが直下と ui で別なので、解決される版はすでにずれて
// いる(直下 20.19.37 / ui 20.19.43)。同じ大版の中の差で、1 つのプログラムに
// 入る型定義は片方だけなので実害は無い。
//
// 大版をそろえる意味は #1314 のときとは変わった。#1314 で踏んだ「直下が 20 の
// まま ui だけ新しくなると出荷物が落ちる」形は、ui/tsconfig.json が types: [] に
// なった時点で出荷物の側からは消えている。いま残る意味は、画面側の検査とサーバー
// 側の Node の版をそろえておくこと。将来 Node を 22 に上げるとき、直下だけ上げた
// 瞬間にこのテストが落ちる——意図した動作で、「ui も一緒に上げろ」の合図。
const uiRange: unknown = readJson('ui/package.json').devDependencies['@types/node'];
const rootRange: unknown = readJson('package.json').devDependencies['@types/node'];
expect(typeof uiRange).toBe('string');
expect(uiRange).toBe(rootRange);
});
});
+86 -14
View File
@@ -2,12 +2,15 @@
// Validates ui/src/content/help/*.md frontmatter + structural invariants.
// Exits non-zero on any error. Run before `vite build`.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { join, dirname, resolve, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HELP_DIR = resolve(__dirname, '../ui/src/content/help');
// 検証するフォルダ。引数で差し替えられるのはテスト用(scripts/validate-help-docs.test.ts)。
const HELP_DIR = process.argv[2]
? resolve(process.cwd(), process.argv[2])
: resolve(__dirname, '../ui/src/content/help');
// `yaml` lives in ui/node_modules (the build runs `node ../scripts/...` from ui/).
// Resolve it relative to ui/ so this script works regardless of cwd.
let parseYaml;
@@ -20,30 +23,41 @@ try {
}
const CATEGORIES = ['basic', 'advanced', 'admin'];
// KEEP IN SYNC with ui/src/lib/help.ts slugify()
function slugify(text) {
const s = text.replace(/<[^>]*>/g, '').trim().toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
return s || 'section';
// 見出し → 飛び先の名前(アンカー)の規則は ui/src/lib/help-slug.ts が唯一の出どころ。
// 画面が実際に付ける id と同じものをここでも使うため、規則を写さず読み込む (#1274)。
// Node は 22.18 以降 .ts をそのまま読める(型を剥がして実行する)。
let slugify, collectHeadingIds;
try {
({ slugify, collectHeadingIds } = await import(
pathToFileURL(resolve(__dirname, '../ui/src/lib/help-slug.ts')).href
));
} catch (e) {
console.error(
'help-docs validation: cannot load ui/src/lib/help-slug.ts — needs Node >= 22.18 ' +
'(TypeScript type stripping) and `npm --prefix ui install`\n ' + (e?.message ?? e),
);
process.exit(1);
}
function splitFrontmatter(raw) {
const n = raw.replace(/^/, '').replace(/\r\n/g, '\n');
const m = /^---\n([\s\S]*?)\n---\n?/.exec(n);
if (!m) return { frontmatter: '', body: n };
return { frontmatter: m[1], body: n.slice(m[0].length) };
if (!m) return { normalized: n, frontmatter: '', body: n };
return { normalized: n, frontmatter: m[1], body: n.slice(m[0].length) };
}
const errors = [];
const ids = new Map();
const orders = new Map();
/** file -> { normalized, body }。リンクの検証で使う。 */
const docs = new Map();
const files = readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).sort();
if (files.length === 0) errors.push('no .md files found in help dir');
for (const file of files) {
const raw = readFileSync(join(HELP_DIR, file), 'utf8');
const { frontmatter, body } = splitFrontmatter(raw);
const { normalized, frontmatter, body } = splitFrontmatter(raw);
if (!frontmatter) { errors.push(`${file}: missing frontmatter block`); continue; }
let data;
try { data = parseYaml(frontmatter); } catch (e) { errors.push(`${file}: YAML error ${e.message}`); continue; }
@@ -76,10 +90,68 @@ for (const file of files) {
if (count > 1) errors.push(`${file}: ${count} headings collide on slug '${slug}' (anchor links ambiguous)`);
}
// Relative markdown links that point at local files must resolve.
for (const m of body.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
const target = resolve(HELP_DIR, m[1].split('#')[0]);
if (!existsSync(target)) errors.push(`${file}: broken relative link ${m[1]}`);
docs.set(file, { normalized, body });
}
// --- Links -----------------------------------------------------------------
// ファイルの存在だけでなく、`#` から後ろ(飛び先の見出し)も突き合わせる。
// 見出しの文言を書き換えたのにリンク側が追いついていない、を落とすため (#1274)。
// 別のページ宛て(`](./xx.md#…)`)と同じページ内(`](#…)`)の両方を見る。
// 片方だけ見張ると「見出しリンクは見張られている」という誤った前提ができる。
/** その本文を描画したときに実際に付く見出し id。ファイル単位で 1 度だけ数える。 */
const headingIdCache = new Map();
function headingIdsOf(file) {
let ids = headingIdCache.get(file);
if (!ids) {
ids = new Set(collectHeadingIds(docs.get(file).body));
headingIdCache.set(file, ids);
}
return ids;
}
/** 文字位置 → 1 始まりの行番号(元ファイルの行と一致させるため normalized 全体で数える)。 */
function lineAt(text, index) {
let line = 1;
for (let i = 0; i < index; i++) if (text[i] === '\n') line++;
return line;
}
function decodeFragment(fragment) {
try { return decodeURIComponent(fragment); } catch { return fragment; }
}
for (const [file, { normalized }] of docs) {
// href の切り出しは以前と同じ(`)` まで)。狭めると、これまで拾えていた
// 書き損じ(空白入りのパスなど)を見落とす。
for (const m of normalized.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
const href = m[1];
const line = lineAt(normalized, m.index);
const where = `${file}:${line}: ${m[0]}`;
const hashAt = href.indexOf('#');
const path = hashAt === -1 ? href : href.slice(0, hashAt);
const fragment = hashAt === -1 ? '' : decodeFragment(href.slice(hashAt + 1));
// 宛先のファイルが在ること。
const target = resolve(HELP_DIR, path);
if (!existsSync(target)) { errors.push(`${where} — target file not found`); continue; }
if (!fragment) continue;
const targetFile = relative(HELP_DIR, target);
if (!docs.has(targetFile)) continue; // ヘルプ以外のファイル(見出しを数えない)
if (!headingIdsOf(targetFile).has(fragment)) {
errors.push(`${where} — no heading '#${fragment}' in ${targetFile}`);
}
}
// 同じページ内のリンク(`](#見出し)`)。飛び先は自分自身の見出し。
for (const m of normalized.matchAll(/\]\((#[^)]+)\)/g)) {
const fragment = decodeFragment(m[1].slice(1));
if (!fragment) continue;
const line = lineAt(normalized, m.index);
if (!headingIdsOf(file).has(fragment)) {
errors.push(`${file}:${line}: ${m[0]} — no heading '#${fragment}' in ${file}`);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// ヘルプの「飛び先の見出し」検証が本当に効くことを守るテスト (#1274)。
//
// 守る対象は「いま何本壊れているか」ではなく、**壊したら赤くなる経路が残って
// いること**。`#` から後ろを見ない作りに戻したら、ここが緑のままになる——それを
// 防ぐために、同じ本文で「合っている / 合っていない」を撮り比べる。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const script = join(repoRoot, 'scripts', 'validate-help-docs.mjs');
let dir: string;
function write(name: string, body: string) {
writeFileSync(join(dir, name), body);
}
function run() {
const r = spawnSync(process.execPath, [script, dir], { encoding: 'utf8' });
return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
}
/** 飛び先になるページ。見出しの深さは 2 と 4 を混ぜてある(後述)。 */
const TARGET = `---
id: target
title: 飛び先
category: basic
order: 1
---
## 接続を切ったとき、置き場の側はどうなるか
本文。
#### 深い見出しにも id は付きます
本文。
`;
/** 同じページの中だけで完結する本文。見出しとそこへのリンクを 1 本ずつ持つ。 */
function selfPage(link: string) {
return `---
id: self
title: 同じページ
category: basic
order: 3
---
## 書き換わったら、押さなくても切り替わります
続きは [上の節](${link}) を参照。
`;
}
function source(link: string) {
return `---
id: source
title: 出どころ
category: basic
order: 2
---
案内は [こちら](${link}) にあります。
`;
}
describe('validate-help-docs.mjs の飛び先検証', () => {
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'help-docs-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('見出しと合っているリンクは通す', () => {
write('01-target.md', TARGET);
write('02-source.md', source('./01-target.md#接続を切ったとき-置き場の側はどうなるか'));
const { code, out } = run();
expect(out).toContain('help-docs validation OK');
expect(code).toBe(0);
});
it('見出しと合っていないリンクを、ファイル名・行番号・リンクの文字列つきで落とす', () => {
write('01-target.md', TARGET);
// 見出しの文言が変わったのにリンクが追いついていない、という壊れ方。
write('02-source.md', source('./01-target.md#接続を切ったとき置き場の側はどうなるか'));
const { code, out } = run();
expect(code).toBe(1);
expect(out).toContain('02-source.md:8:');
expect(out).toContain('](./01-target.md#接続を切ったとき置き場の側はどうなるか)');
expect(out).toContain("no heading '#接続を切ったとき置き場の側はどうなるか' in 01-target.md");
});
it('h2/h3 以外の見出しへのリンクも通す(画面は深さによらず id を付けるため)', () => {
write('01-target.md', TARGET);
write('02-source.md', source('./01-target.md#深い見出しにも-id-は付きます'));
const { code } = run();
expect(code).toBe(0);
});
it('同じページの中のリンクも、見出しと合っていれば通す', () => {
write('03-self.md', selfPage('#書き換わったら-押さなくても切り替わります'));
const { code, out } = run();
expect(out).toContain('help-docs validation OK');
expect(code).toBe(0);
});
it('同じページの中のリンクも、合っていなければ名指しして落とす', () => {
write('03-self.md', selfPage('#書き換わったら押さなくても切り替わります'));
const { code, out } = run();
expect(code).toBe(1);
expect(out).toContain('03-self.md:10:');
expect(out).toContain('](#書き換わったら押さなくても切り替わります)');
expect(out).toContain("no heading '#書き換わったら押さなくても切り替わります' in 03-self.md");
});
it('リンク先のファイルが無いときは、これまでどおり落とす', () => {
write('01-target.md', TARGET);
write('02-source.md', source('./99-missing.md#どこか'));
const { code, out } = run();
expect(code).toBe(1);
expect(out).toContain('target file not found');
});
});
@@ -0,0 +1,20 @@
import { cp, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const sourceDist = join(root, 'dist');
const sandbox = await mkdtemp(join(tmpdir(), 'maestro-worker-contract-'));
const stagedDist = join(sandbox, 'dist');
try {
await cp(sourceDist, stagedDist, { recursive: true });
const selector = await import(pathToFileURL(join(stagedDist, 'answer-workers', 'select-answer-worker.js')).href);
if (typeof selector.selectAnswerWorker !== 'function') {
throw new Error('dist selector did not resolve selectAnswerWorker');
}
console.log('answer-worker dist runtime smoke OK');
} finally {
await rm(sandbox, { recursive: true, force: true });
}
+4 -4
View File
@@ -3,7 +3,7 @@
# twikit: ホームタイムラインの GraphQL endpoint / FEATURES 定義の供給元
# httpx: home timeline の raw GraphQL POST
# PyYAML: twitter-cli 互換 YAML の出力
twscrape>=0.19.0
twikit>=2.3.3
httpx>=0.27
PyYAML>=6.0
twscrape==0.19.2
twikit==2.3.3
httpx==0.28.1
PyYAML==6.0.3
+74
View File
@@ -278,6 +278,80 @@ try:
except ImportError:
print("SKIP emit tests (PyYAML not installed)")
# ── txid indices deep scan ──
# X が 2026-07 に未ログインページを x-web SSR 化し、indices ファイル (sign.o-*.js) が
# entry チャンクの動的 import 先 (assets/*.js) に移った。twscrape 0.19.1 はページ直下の
# スクリプトしか走査しないため到達できない。以下はもう 1 ホップ潜る実装のテスト。
ENTRY = "https://abs.twimg.com/x-web/x-web/entry-client-logged-out-B9F1BBbe.js"
check("chunkrefs: resolves ./assets relative to entry",
"https://abs.twimg.com/x-web/x-web/assets/a-1234.js"
in xa.collect_chunk_refs('import("./assets/a-1234.js")', ENTRY))
check("chunkrefs: resolves parent-relative",
"https://abs.twimg.com/x-web/messages/ja.js"
in xa.collect_chunk_refs('x="../messages/ja.js"', ENTRY))
check("chunkrefs: assets sorted ahead of non-assets",
xa.collect_chunk_refs('"../messages/ja.js";"./assets/z-9.js"', ENTRY)[0].endswith("/assets/z-9.js"))
check("chunkrefs: dedupes",
len(xa.collect_chunk_refs('"./assets/a-1.js" "./assets/a-1.js"', ENTRY)) == 1)
check("chunkrefs: ignores absolute cross-origin",
xa.collect_chunk_refs('"https://evil.example/x.js"', ENTRY) == [])
check("chunkrefs: caps fan-out",
len(xa.collect_chunk_refs(" ".join(f'"./assets/c-{i}.js"' for i in range(500)), ENTRY))
<= xa.CHUNK_SCAN_CAP)
class _FakeClient:
"""twscrape の HttpClient 代替。get(url).text を返すだけ。"""
def __init__(self, bodies):
self.bodies = bodies
self.fetched = []
async def get(self, url):
self.fetched.append(url)
return types.SimpleNamespace(text=self.bodies.get(url, ""))
def _run(coro):
import asyncio
return asyncio.run(coro)
SIGN_O = "https://abs.twimg.com/x-web/x-web/assets/sign.o-BS4wvWaM.js"
SUB = "https://abs.twimg.com/x-web/x-web/assets/sentry-filter-CAS6J6cR.js"
# 1 段目に直接ある場合 (旧 = 上流と同じ挙動) は 1 ホップで返す。
# entry と同じ階層で解決されるので assets/ は挟まらない。
c = _FakeClient({ENTRY: 'import("./sign.o-BS4wvWaM.js")'})
check("deepscan: level-1 hit",
_run(xa.find_indices_url_deep([ENTRY], c))
== "https://abs.twimg.com/x-web/x-web/sign.o-BS4wvWaM.js")
# 2 段目 (今回の実際の構造): entry -> assets/sentry-filter -> sign.o
c = _FakeClient({ENTRY: 'import("./assets/sentry-filter-CAS6J6cR.js")',
SUB: 'import("./sign.o-BS4wvWaM.js")'})
check("deepscan: level-2 hit",
_run(xa.find_indices_url_deep([ENTRY], c)) == SIGN_O)
# どこにも無ければ例外を上げ、txid 失敗を記録する (静かな空振りにしない)
xa.TXID_STATE["failed"] = False
c = _FakeClient({ENTRY: 'import("./assets/nothing-here.js")'})
_raised = False
try:
_run(xa.find_indices_url_deep([ENTRY], c))
except Exception:
_raised = True
check("deepscan: raises when absent", _raised)
check("deepscan: records txid failure", xa.TXID_STATE["failed"] is True)
# 成功時は失敗フラグをクリアする (再試行で回復したケース)
xa.TXID_STATE["failed"] = True
c = _FakeClient({ENTRY: 'import("./sign.o-BS4wvWaM.js")'})
_run(xa.find_indices_url_deep([ENTRY], c))
check("deepscan: clears flag on success", xa.TXID_STATE["failed"] is False)
print()
if _fails:
print(f"{len(_fails)} FAILED: {_fails}")
+115 -4
View File
@@ -402,6 +402,108 @@ def _silence_logs() -> None:
pass
# ── txid: indices スクリプトの深掘り探索 ──────────────────────────────
#
# 2026-07 に X が未ログインページを x-web の SSR 版へ寄せた。ページ直下の <script> は
# entry チャンク 1 本だけになり、署名用 indices ファイル (sign.o-*.js) はその entry が
# 動的 import する assets/*.js のさらに中から参照される形に変わった。
# twscrape 0.19.1 の _find_indices_url は 1 段目しか走査しないので到達できず、
# "Couldn't get XClientTxId indices script" で txid 生成が落ちる。txid が無いリクエストは
# X 側が黙って 0 件を返すため、症状は「エラー無しで常に空」になる (上流 issue #320)。
#
# 上流 PR #321 は「cookie 付きで取れば indices を含むバンドルが来る」という案だが、
# 実測ではこちらの認証セッションには旧 responsive-web バンドルが配信され、そこに
# indices は含まれていなかった。よって cookie 経路には頼らず、未ログインのまま
# もう 1 ホップ潜る。上流ロジックは残し、この関数だけ差し替える。
CHUNK_SCAN_CAP = 200 # 1 段目 1 チャンクあたりに追跡するサブチャンク数の上限
CHUNK_FETCH_CONCURRENCY = 16
# バンドル内の相対 import (`"./assets/x-Hash.js"` / `'../messages/ja.js'`) を拾う。
# 絶対 URL は別オリジンを踏みに行かないよう意図的に対象外。
_CHUNK_REF_RE = re.compile(r"""["'(](\.{1,2}/[\w./-]*?\.js)["')]""")
# indices ファイル名。上流 twscrape の INDICES_FILE_RE と同義だが、テストを
# twscrape 非依存で回すため自前に持つ (上流のリネームからも切り離される)。
# \b は design.o-*.js のような部分一致除け。
INDICES_FILE_RE = re.compile(r"(?:\.{0,2}/)?[\w./-]*?\b(?:ondemand\.s|sign\.o)[\w.-]*\.js")
TXID_STATE = {"failed": False}
def collect_chunk_refs(body: str, base_url: str) -> list[str]:
"""チャンク本文から、次に辿るべきサブチャンクの絶対 URL 一覧を作る。
indices は assets/ 配下にあるので assets/ を含む参照を先に試す (i18n の
../messages/*.js が数百件あり、後回しにしないと無駄打ちが増える)。
"""
from urllib.parse import urljoin
seen, assets, others = set(), [], []
for m in _CHUNK_REF_RE.finditer(body):
url = urljoin(base_url, m.group(1))
if url in seen:
continue
seen.add(url)
(assets if "/assets/" in url else others).append(url)
return (assets + others)[:CHUNK_SCAN_CAP]
async def find_indices_url_deep(scripts: list, clt) -> str:
"""indices ファイル (sign.o-*.js / ondemand.s*.js) の URL を解決する。
1 段目 = 上流と同じくページ直下のスクリプト本文を走査。見つからなければ
2 段目 = 各スクリプトが import している相対チャンクを走査する。
どこにも無ければ TXID_STATE に記録した上で例外を上げる (握り潰さない)。
"""
import asyncio
from urllib.parse import urljoin
sem = asyncio.Semaphore(CHUNK_FETCH_CONCURRENCY)
async def fetch(url: str) -> tuple[str, str]:
async with sem:
try:
return url, (await clt.get(url)).text
except Exception:
return url, ""
async def scan(urls: list) -> tuple[str, list]:
"""走査してヒット URL を返す。未ヒットなら次段の候補も併せて返す。"""
nxt = []
tasks = [asyncio.ensure_future(fetch(u)) for u in urls]
try:
for fut in asyncio.as_completed(tasks):
url, body = await fut
m = INDICES_FILE_RE.search(body)
if m:
return urljoin(url, m.group(0)), []
nxt.extend(collect_chunk_refs(body, url))
finally:
for t in tasks:
t.cancel()
return "", nxt
hit, level2 = await scan(list(scripts))
if not hit and level2:
hit, _ = await scan(list(dict.fromkeys(level2)))
if not hit:
TXID_STATE["failed"] = True
raise Exception("Couldn't get XClientTxId indices script")
TXID_STATE["failed"] = False
return hit
def _install_xclid_deep_scan() -> None:
"""twscrape の indices 探索を深掘り版へ差し替える (import 済みが前提)。"""
try:
import twscrape.xclid as _xclid
_xclid._find_indices_url = find_indices_url_deep
except Exception:
pass
# ── backend (twscrape / twikit、遅延 import) ──────────────────────────
def _creds() -> tuple[str, str]:
@@ -441,12 +543,19 @@ def _cleanup_stale_tmp() -> None:
async def _ensure_alive(api) -> None:
"""直近のリクエストでアカウントが 401/banned 扱いになっていないか確認。
"""空結果が『本当に0件』か『取得できていない』かを切り分け、後者は nonzero で落とす。
twscrape は 401/429 を例外にせずログして停止し、空イテレーションを返す。空結果が
『本当に0件』か『認証切れ/レート制限』かを区別するため、停止時に無効化される
account.active を見て、無効なら nonzero で fail する (x.ts がエラー表示)。
twscrape は 401/429 も txid 生成失敗も例外にせずログして停止し、空イテレーションを
返す。黙って空を返すと利用者からは「エラーも出ないのに何も取れない」に見えるので、
原因が分かる形で fail させる (x.ts がそのまま表示する)。
- txid 生成失敗 … X のバンドル構成変更。TXID_STATE で検知
- 認証切れ/BAN/レート制限 … 停止時に account.active が落ちる
"""
if TXID_STATE.get("failed"):
fail("x-adapter: could not build the X request signature (x-client-transaction-id); "
"X changed its web bundle layout again. Every request returns empty until the "
"indices lookup is updated. See scripts/x-adapter/x_adapter.py "
"(find_indices_url_deep) and twscrape upstream issue #320.")
try:
accs = await api.pool.get_all()
except Exception:
@@ -463,6 +572,7 @@ async def _make_api():
import tempfile
from twscrape import API
_silence_logs() # twscrape import 後に呼ぶ (import 時に loguru sink が張られるため)
_install_xclid_deep_scan()
auth, ct0 = _creds()
tmpdir = tempfile.mkdtemp(prefix=_TMP_PREFIX)
db = os.path.join(tmpdir, "accounts.db")
@@ -601,6 +711,7 @@ async def cmd_feed(args) -> None:
from twikit.client.gql import Endpoint
from twikit.constants import FEATURES
_silence_logs() # twscrape import 後に呼ぶ
_install_xclid_deep_scan() # feed は _make_api を通らないのでここでも要適用
auth, ct0 = _creds()
following = (args.tab or "").strip().lower() == "following"