sync: update from private repo (f6d625db)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions

3
.gitignore vendored
View File

@ -55,3 +55,6 @@ data/browser-sessions/*
# are named `core` or `core.<pid>`, so match only a numeric suffix.
core
core.[0-9]*
# Playwright E2E: throwaway DB path handoff (config → specs)
ui/.e2e-db-path

View File

@ -52,6 +52,10 @@ FROM node:22-bookworm-slim AS runtime
# - xvfb/x11vnc/websockify: the noVNC display stack (display_mode: novnc) that
# powers the Browser tab live view, InteractiveBrowse, and the CAPTCHA pool
# - fonts-*: legible text (incl. CJK) in the headed browser / screenshots
# - libreoffice-impress: headless .pptx/.ppt -> PDF conversion for the file-
# preview feature (office-preview.ts). The PDF is then rasterised to PNG via
# the pre-baked PyMuPDF (fitz). --no-install-recommends keeps the image lean
# (skips the GUI/java extras); CJK glyphs come from the fonts-noto-cjk above.
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
@ -65,6 +69,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \
fonts-liberation \
fonts-noto-cjk \
libreoffice-impress \
&& rm -rf /var/lib/apt/lists/*
# Pre-bake python packages into the system site-packages (read-only bind-mounted

View File

@ -0,0 +1,5 @@
{
"title": "ダッシュボード",
"description": "output/ を集計して表示",
"entry": "index.html"
}

View File

@ -0,0 +1,15 @@
{
"app": "dashboard",
"seed_files": [
{ "path": "output/a.txt", "content": "x\ny" },
{ "path": "output/b.txt", "content": "z" }
],
"steps": [
{ "type": "click", "selector": "[data-testid=\"refresh\"]" },
{ "type": "getText", "selector": "[data-testid=\"summary\"]" }
],
"expect": [
{ "kind": "text", "target": "[data-testid=\"summary\"]", "contains": "ファイル数" },
{ "kind": "text", "target": "[data-testid=\"status\"]", "contains": "更新OK" }
]
}

View File

@ -0,0 +1,72 @@
<!doctype html><html lang="ja"><head><meta charset="utf-8" />
<style>
body{font:14px system-ui;margin:0;padding:16px}
.row{display:flex;gap:8px;align-items:center;margin-bottom:12px}
#summary{font-size:16px;font-weight:bold;margin-bottom:16px;min-height:24px}
.bar-wrap{margin-bottom:16px}
.bar-label{font-size:12px;margin-bottom:2px;color:#444}
svg.bar{display:block;width:100%;max-width:400px;height:20px;border-radius:4px;overflow:hidden;background:#eee}
rect.bar-fill{fill:#2563eb}
span[data-testid="status"]{font-size:12px;color:#555}
button{padding:6px 16px;border-radius:4px;border:none;background:#2563eb;color:#fff;cursor:pointer}
button:hover{background:#1d4ed8}
</style></head>
<body>
<div class="row">
<button id="refreshBtn" data-testid="refresh">更新</button>
<span id="status" data-testid="status"></span>
</div>
<div id="summary" data-testid="summary"></div>
<div id="bars"></div>
<script>
let seq=0;const pending=new Map();
function call(req){return new Promise((res,rej)=>{const id=++seq;pending.set(id,{res,rej});
window.parent.postMessage({...req,id},'*');});}
window.addEventListener('message',e=>{const r=e.data;const p=pending.get(r&&r.id);if(!p)return;
pending.delete(r.id);r.ok?p.res(r.data):p.rej(new Error(r.error));});
const $=id=>document.getElementById(id);
const TEXT_EXTS=['.txt','.md','.csv','.json','.jsonl','.log','.yaml','.yml'];
function isText(name){return TEXT_EXTS.some(e=>name.endsWith(e));}
async function refresh(){
$('status').textContent='集計中…';
$('summary').textContent='';
$('bars').innerHTML='';
try{
const d=await call({type:'listFiles',dir:'output'});
const entries=d.entries||[];
const count=entries.length;
const lineCounts=[];
for(const name of entries){
if(!isText(name)){lineCounts.push({name,lines:null});continue;}
try{
const fd=await call({type:'readFile',path:'output/'+name});
const lines=(fd.content||'').split('\n').filter(l=>l.length>0).length;
lineCounts.push({name,lines});
}catch(_){lineCounts.push({name,lines:null});}
}
$('summary').textContent='ファイル数: '+count;
const withLines=lineCounts.filter(x=>x.lines!==null);
const maxLines=Math.max(...withLines.map(x=>x.lines),1);
withLines.forEach(({name,lines})=>{
const wrap=document.createElement('div');wrap.className='bar-wrap';
const lbl=document.createElement('div');lbl.className='bar-label';
lbl.textContent=name+' ('+lines+'行)';
const pct=Math.round((lines/maxLines)*100);
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('class','bar');svg.setAttribute('viewBox','0 0 100 20');
svg.setAttribute('preserveAspectRatio','none');
const rect=document.createElementNS('http://www.w3.org/2000/svg','rect');
rect.setAttribute('class','bar-fill');rect.setAttribute('x','0');rect.setAttribute('y','0');
rect.setAttribute('width',String(pct));rect.setAttribute('height','20');
svg.appendChild(rect);wrap.appendChild(lbl);wrap.appendChild(svg);
$('bars').appendChild(wrap);
});
$('status').textContent='更新OK';
}catch(err){$('status').textContent='エラー:'+err.message;}
}
$('refreshBtn').onclick=refresh;
refresh();
</script></body></html>

View File

@ -0,0 +1,5 @@
{
"title": "データビューア",
"description": "output/ のファイルを一覧・閲覧",
"entry": "index.html"
}

View File

@ -0,0 +1,14 @@
{
"app": "data-viewer",
"seed_files": [
{ "path": "output/data.csv", "content": "name,age\nalice,30\nbob,25" }
],
"steps": [
{ "type": "click", "selector": "[data-testid=\"list\"]" },
{ "type": "getText", "selector": "[data-testid=\"files\"]" }
],
"expect": [
{ "kind": "text", "target": "[data-testid=\"files\"]", "contains": "data.csv" },
{ "kind": "text", "target": "[data-testid=\"status\"]", "contains": "取得OK" }
]
}

View File

@ -0,0 +1,81 @@
<!doctype html><html lang="ja"><head><meta charset="utf-8" />
<style>
body{font:14px system-ui;margin:0;padding:16px}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
ul{list-style:none;padding:0;margin:0}
li[data-testid="file-item"]{cursor:pointer;padding:4px 8px;border-radius:4px;background:#f0f0f0;margin-bottom:4px}
li[data-testid="file-item"]:hover{background:#dce8ff}
pre[data-testid="preview"]{background:#f9f9f9;border:1px solid #ddd;padding:12px;overflow:auto;max-height:50vh;white-space:pre-wrap}
table{border-collapse:collapse;width:100%}
table th,table td{border:1px solid #ccc;padding:4px 8px;text-align:left}
table th{background:#eee}
</style></head>
<body>
<div class="row">
<input id="dir" data-testid="dir" value="output" style="flex:1" />
<button id="listBtn" data-testid="list">一覧取得</button>
<span id="status" data-testid="status"></span>
</div>
<ul id="files" data-testid="files"></ul>
<div id="preview-wrap"></div>
<script>
let seq=0;const pending=new Map();
function call(req){return new Promise((res,rej)=>{const id=++seq;pending.set(id,{res,rej});
window.parent.postMessage({...req,id},'*');});}
window.addEventListener('message',e=>{const r=e.data;const p=pending.get(r&&r.id);if(!p)return;
pending.delete(r.id);r.ok?p.res(r.data):p.rej(new Error(r.error));});
const $=id=>document.getElementById(id);
function renderCsv(text){
const rows=text.trim().split('\n').map(r=>r.split(','));
const table=document.createElement('table');
rows.forEach((cols,i)=>{const tr=document.createElement('tr');
cols.forEach(c=>{const cell=document.createElement(i===0?'th':'td');cell.textContent=c;tr.appendChild(cell);});
table.appendChild(tr);});
return table;
}
async function listDir(){
const dir=$('dir').value.trim()||'output';
$('status').textContent='取得中…';
$('files').innerHTML='';
$('preview-wrap').innerHTML='';
try{
const d=await call({type:'listFiles',dir});
const entries=d.entries||[];
if(entries.length===0){$('files').innerHTML='<li>(ファイルなし)</li>';}
entries.forEach(name=>{
const li=document.createElement('li');
li.setAttribute('data-testid','file-item');
li.textContent=name;
li.onclick=()=>openFile(dir,name);
$('files').appendChild(li);
});
$('status').textContent='取得OK';
}catch(err){$('status').textContent='エラー:'+err.message;}
}
async function openFile(dir,name){
$('status').textContent='読込中…';
$('preview-wrap').innerHTML='';
try{
const path=dir.replace(/\/$/,'')+'/'+name;
const d=await call({type:'readFile',path});
const content=d.content||'';
if(name.endsWith('.csv')){
const wrap=document.createElement('div');
wrap.setAttribute('data-testid','preview');
wrap.appendChild(renderCsv(content));
$('preview-wrap').appendChild(wrap);
}else{
const pre=document.createElement('pre');
pre.setAttribute('data-testid','preview');
pre.textContent=content;
$('preview-wrap').appendChild(pre);
}
$('status').textContent='読込OK';
}catch(err){$('status').textContent='読込失敗:'+err.message;}
}
$('listBtn').onclick=listDir;
</script></body></html>

View File

@ -0,0 +1,5 @@
{
"title": "フォーム入力",
"description": "入力を output/ に構造化保存",
"entry": "index.html"
}

View File

@ -0,0 +1,14 @@
{
"app": "form-input",
"seed_files": [],
"steps": [
{ "type": "fill", "selector": "[data-testid=\"f-name\"]", "value": "alice" },
{ "type": "fill", "selector": "[data-testid=\"f-note\"]", "value": "hello-e2e" },
{ "type": "click", "selector": "[data-testid=\"submit\"]" },
{ "type": "getText", "selector": "[data-testid=\"status\"]" }
],
"expect": [
{ "kind": "text", "target": "[data-testid=\"status\"]", "contains": "保存" },
{ "kind": "file", "target": "output/submissions.jsonl", "contains": "hello-e2e" }
]
}

View File

@ -0,0 +1,49 @@
<!doctype html><html lang="ja"><head><meta charset="utf-8" />
<style>
body{font:14px system-ui;margin:0;padding:16px}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
input[type="text"],textarea{border:1px solid #ccc;border-radius:4px;padding:6px 8px;width:100%;box-sizing:border-box}
label{display:block;font-weight:bold;margin-bottom:4px;margin-top:12px}
button{padding:6px 16px;border-radius:4px;border:none;background:#2563eb;color:#fff;cursor:pointer}
button:hover{background:#1d4ed8}
span[data-testid="status"]{font-size:12px;color:#555}
</style></head>
<body>
<label for="f-name">名前</label>
<input type="text" id="f-name" data-testid="f-name" placeholder="名前を入力" />
<label for="f-note">メモ</label>
<input type="text" id="f-note" data-testid="f-note" placeholder="メモを入力" />
<div class="row" style="margin-top:16px">
<button id="submitBtn" data-testid="submit">保存</button>
<span id="status" data-testid="status"></span>
</div>
<script>
let seq=0;const pending=new Map();
function call(req){return new Promise((res,rej)=>{const id=++seq;pending.set(id,{res,rej});
window.parent.postMessage({...req,id},'*');});}
window.addEventListener('message',e=>{const r=e.data;const p=pending.get(r&&r.id);if(!p)return;
pending.delete(r.id);r.ok?p.res(r.data):p.rej(new Error(r.error));});
const $=id=>document.getElementById(id);
const DEST='output/submissions.jsonl';
async function submit(){
const name=$('f-name').value.trim();
const note=$('f-note').value.trim();
if(!name&&!note){$('status').textContent='入力してください';return;}
$('status').textContent='保存中…';
try{
let existing='';
try{const d=await call({type:'readFile',path:DEST});existing=d.content||'';}catch(_){}
const line=JSON.stringify({name,note,ts:new Date().toISOString()});
const updated=(existing.endsWith('\n')||existing==='')
?existing+line+'\n'
:existing+'\n'+line+'\n';
await call({type:'writeFile',path:DEST,content:updated});
$('f-name').value='';$('f-note').value='';
$('status').textContent='保存しました';
}catch(err){$('status').textContent='保存失敗:'+err.message;}
}
$('submitBtn').onclick=submit;
</script></body></html>

View File

@ -0,0 +1,5 @@
{
"title": "ノートエディタ",
"description": "output/ のテキストを編集して保存",
"entry": "index.html"
}

View File

@ -0,0 +1,15 @@
{
"app": "note-editor",
"seed_files": [{ "path": "output/note.md", "content": "seeded-content" }],
"steps": [
{ "type": "click", "selector": "[data-testid=\"load\"]" },
{ "type": "getText", "selector": "[data-testid=\"status\"]" },
{ "type": "fill", "selector": "[data-testid=\"body\"]", "value": "edited-by-e2e" },
{ "type": "click", "selector": "[data-testid=\"save\"]" },
{ "type": "getText", "selector": "[data-testid=\"status\"]" }
],
"expect": [
{ "kind": "text", "target": "[data-testid=\"status\"]", "contains": "保存OK" },
{ "kind": "file", "target": "output/note.md", "contains": "edited-by-e2e" }
]
}

View File

@ -0,0 +1,25 @@
<!doctype html><html lang="ja"><head><meta charset="utf-8" />
<style>body{font:14px system-ui;margin:0;padding:16px}textarea{width:100%;height:60vh}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}</style></head>
<body>
<div class="row">
<input id="path" data-testid="path" value="output/note.md" style="flex:1" />
<button id="load" data-testid="load">読み込み</button>
<button id="save" data-testid="save">保存</button>
<span id="status" data-testid="status"></span>
</div>
<textarea id="body" data-testid="body"></textarea>
<script>
let seq=0;const pending=new Map();
function call(req){return new Promise((res,rej)=>{const id=++seq;pending.set(id,{res,rej});
window.parent.postMessage({...req,id},'*');});}
window.addEventListener('message',e=>{const r=e.data;const p=pending.get(r&&r.id);if(!p)return;
pending.delete(r.id);r.ok?p.res(r.data):p.rej(new Error(r.error));});
const $=id=>document.getElementById(id);
async function load(){try{const d=await call({type:'readFile',path:$('path').value});
$('body').value=d.content;$('status').textContent='読込OK';}catch(err){$('status').textContent='読込失敗:'+err.message;}}
async function save(){try{await call({type:'writeFile',path:$('path').value,content:$('body').value});
$('status').textContent='保存OK';}catch(err){$('status').textContent='保存失敗:'+err.message;}}
$('load').onclick=load;$('save').onclick=save;
load();
</script></body></html>

768
docs/features/index.html Normal file
View File

@ -0,0 +1,768 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MAESTRO 全機能カタログ &amp; テストカバレッジ</title>
<style>
:root{
--bg:#f6f7f9; --card:#ffffff; --ink:#1f2733; --muted:#5b6675; --line:#e3e7ec;
--full:#1a7f37; --full-bg:#e6f4ea; --partial:#b3791a; --partial-bg:#fdf3e0;
--none:#c0362c; --none-bg:#fbe9e7; --other:#6b7280; --other-bg:#eef0f2;
--accent:#2563eb;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Hiragino Kaku Gothic ProN","Noto Sans JP",Meiryo,sans-serif;
line-height:1.6;font-size:14px;}
code,.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:.86em;}
code{background:#eef1f4;border-radius:4px;padding:1px 5px;color:#243; word-break:break-word;}
.wrap{max-width:1480px;margin:0 auto;padding:24px 20px 80px;}
header.page{margin-bottom:8px;}
.stage-badge{display:inline-block;font-weight:700;font-size:12px;letter-spacing:.04em;
padding:5px 14px;border-radius:999px;background:#dbeafe;color:#1e40af;border:1px solid #bfdbfe;}
.stage-legend{display:inline-flex;gap:6px;margin-left:10px;font-size:11px;color:var(--muted);align-items:center;}
.stage-legend span{display:inline-flex;align-items:center;gap:4px;}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;}
.dot.b{background:#3b82f6}.dot.y{background:#eab308}.dot.g{background:#22c55e}
h1{font-size:26px;margin:12px 0 4px;}
.sub{color:var(--muted);margin:0 0 6px;}
.links{margin:10px 0 22px;font-size:13px;}
.links a{color:var(--accent);text-decoration:none;margin-right:18px;}
.links a:hover{text-decoration:underline;}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px 22px;margin:18px 0;
box-shadow:0 1px 2px rgba(20,30,50,.04);}
h2{font-size:18px;margin:0 0 14px;border-left:4px solid var(--accent);padding-left:10px;}
h3{font-size:15px;margin:18px 0 6px;color:#27313f;}
.stat-row{display:flex;flex-wrap:wrap;gap:14px;margin-bottom:18px;}
.stat{flex:1 1 150px;border:1px solid var(--line);border-radius:10px;padding:14px 16px;background:#fbfcfd;}
.stat .n{font-size:30px;font-weight:700;line-height:1;}
.stat .l{font-size:12px;color:var(--muted);margin-top:6px;}
.stat.full .n{color:var(--full)} .stat.partial .n{color:var(--partial)} .stat.none .n{color:var(--none)}
.chip{display:inline-block;font-weight:700;font-size:11px;padding:2px 9px;border-radius:999px;letter-spacing:.02em;white-space:nowrap;}
.cov-full{background:var(--full-bg);color:var(--full);}
.cov-partial{background:var(--partial-bg);color:var(--partial);}
.cov-none{background:var(--none-bg);color:var(--none);}
.cov-other{background:var(--other-bg);color:var(--other);}
table{border-collapse:collapse;width:100%;font-size:13px;}
.area-table th,.area-table td{border:1px solid var(--line);padding:8px 10px;text-align:left;}
.area-table th{background:#f0f3f7;font-weight:600;}
.area-table td.num{text-align:center;}
.area-table tr.total-row td{font-weight:700;background:#f7f9fc;}
.controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px;}
#search{flex:1 1 280px;min-width:220px;padding:9px 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;}
.filt-btns{display:flex;gap:6px;flex-wrap:wrap;}
.fbtn{cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:8px;padding:7px 14px;font-size:12px;font-weight:600;color:var(--muted);}
.fbtn.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.fbtn[data-cov="FULL"].active{background:var(--full);border-color:var(--full);}
.fbtn[data-cov="PARTIAL"].active{background:var(--partial);border-color:var(--partial);}
.fbtn[data-cov="NONE"].active{background:var(--none);border-color:var(--none);}
#count{font-size:12px;color:var(--muted);margin-left:auto;}
.table-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:10px;}
table.master{font-size:12.5px;}
table.master th,table.master td{padding:7px 9px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left;}
table.master thead th{position:sticky;top:0;background:#eef2f7;z-index:2;font-weight:600;border-bottom:2px solid #d4dbe4;}
table.master tbody tr:hover{background:#f5f8fc;}
.c-id{white-space:nowrap;} .c-area{white-space:nowrap;color:var(--muted);}
.c-feature{font-weight:600;min-width:140px;}
.c-behavior{min-width:240px;color:#33404f;}
.c-source{min-width:150px;} .c-test{min-width:200px;color:#445;}
.c-cov{white-space:nowrap;text-align:center;} .c-gap{min-width:200px;color:#6a7686;}
.gap-area{margin-bottom:6px;}
ol.gap-list{margin:4px 0 14px;padding-left:22px;}
ol.gap-list li{margin-bottom:8px;}
.muted-note{font-size:12px;color:var(--muted);margin-top:6px;}
footer{margin-top:40px;color:var(--muted);font-size:12px;text-align:center;}
@media (max-width:680px){ h1{font-size:21px;} .wrap{padding:16px 12px 60px;} }
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<span class="stage-badge">設計</span>
<span class="stage-legend">
<span><i class="dot b"></i>設計</span>
<span><i class="dot y"></i>実装</span>
<span><i class="dot g"></i>完成</span>
</span>
<h1>MAESTRO 全機能カタログ &amp; テストカバレッジ</h1>
<p class="sub">8 領域・502 機能のインベントリと既存テストのカバレッジ一覧TDD 機能テスト計画)</p>
<div class="links">
<a href="../superpowers/specs/2026-06-25-tdd-functional-tests-design.md">&#128279; 設計仕様 (design spec)</a>
<a href="#summary">サマリ</a>
<a href="#master">機能一覧</a>
<a href="#gaps">重要ギャップ</a>
</div>
</header>
<section class="card" id="summary">
<h2>サマリ</h2>
<div class="stat-row">
<div class="stat"><div class="n">502</div><div class="l">機能総数 (Total features)</div></div>
<div class="stat full"><div class="n">352</div><div class="l">FULL カバー済み</div></div>
<div class="stat partial"><div class="n">124</div><div class="l">PARTIAL 一部のみ</div></div>
<div class="stat none"><div class="n">26</div><div class="l">NONE 未カバー</div></div>
</div>
<table class="area-table">
<thead><tr><th>領域 (Area)</th><th>機能数</th><th>FULL</th><th>PARTIAL</th><th>NONE</th></tr></thead>
<tbody>
<tr><td>Engine core</td><td class="num">65</td><td class="num"><span class="chip cov-full">52</span></td><td class="num"><span class="chip cov-partial">9</span></td><td class="num"><span class="chip cov-none">4</span></td></tr>
<tr><td>Tools</td><td class="num">56</td><td class="num"><span class="chip cov-full">32</span></td><td class="num"><span class="chip cov-partial">22</span></td><td class="num"><span class="chip cov-none">2</span></td></tr>
<tr><td>Pieces</td><td class="num">32</td><td class="num"><span class="chip cov-full">18</span></td><td class="num"><span class="chip cov-partial">13</span></td><td class="num"><span class="chip cov-none">1</span></td></tr>
<tr><td>Reflection</td><td class="num">61</td><td class="num"><span class="chip cov-full">51</span></td><td class="num"><span class="chip cov-partial">10</span></td><td class="num"><span class="chip cov-none">0</span></td></tr>
<tr><td>Bridge core</td><td class="num">50</td><td class="num"><span class="chip cov-full">39</span></td><td class="num"><span class="chip cov-partial">9</span></td><td class="num"><span class="chip cov-none">2</span></td></tr>
<tr><td>Bridge auth &amp; spaces</td><td class="num">66</td><td class="num"><span class="chip cov-full">48</span></td><td class="num"><span class="chip cov-partial">17</span></td><td class="num"><span class="chip cov-none">1</span></td></tr>
<tr><td>Services &amp; DB</td><td class="num">87</td><td class="num"><span class="chip cov-full">69</span></td><td class="num"><span class="chip cov-partial">13</span></td><td class="num"><span class="chip cov-none">5</span></td></tr>
<tr><td>UI</td><td class="num">85</td><td class="num"><span class="chip cov-full">43</span></td><td class="num"><span class="chip cov-partial">31</span></td><td class="num"><span class="chip cov-none">11</span></td></tr>
<tr class="total-row"><td>合計</td><td class="num">502</td><td class="num"><span class="chip cov-full">352</span></td><td class="num"><span class="chip cov-partial">124</span></td><td class="num"><span class="chip cov-none">26</span></td></tr>
</tbody>
</table>
<p class="muted-note">本ページの数値は 8 つのインベントリ表をパースして算出した実数502 件)。設計仕様 (design spec) の概算目標値は 483 件 (FULL 318 / PARTIAL 137 / NONE 29)。差分はインベントリ確定時の項目追加・分類更新による。</p>
</section>
<section class="card" id="master">
<h2>機能一覧 (Master feature table)</h2>
<div class="controls">
<input id="search" type="text" placeholder="検索: 機能名・ID・ソース・テスト… (live filter)">
<div class="filt-btns">
<button class="fbtn active" data-cov="ALL">すべて</button>
<button class="fbtn" data-cov="FULL">FULL</button>
<button class="fbtn" data-cov="PARTIAL">PARTIAL</button>
<button class="fbtn" data-cov="NONE">NONE</button>
</div>
<span id="count"></span>
</div>
<div class="table-scroll">
<table class="master" id="master-table">
<thead><tr>
<th>ID</th><th>領域</th><th>機能</th><th>挙動</th><th>ソース</th><th>既存テスト</th><th>カバレッジ</th><th>ギャップ</th>
</tr></thead>
<tbody>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-001</code></td><td class="c-area">Engine core</td><td class="c-feature">Parallel read-tool execution</td><td class="c-behavior">Consecutive PARALLEL_SAFE tools run via <code>Promise.all</code>; side-effecting tools act as a sequential barrier</td><td class="c-source"><code>agent-loop.ts (`canExecuteInParallel`, `PARALLEL_SAFE_TOOL_NAMES`, executeMovement)</code></td><td class="c-test">agent-loop.test.ts &quot;runs consecutive safe tool calls in parallel&quot;; &quot;keeps side-effecting tools sequential as a barrier&quot;; &quot;executes regular tools before transition even if transition appears mid-batch&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-002</code></td><td class="c-area">Engine core</td><td class="c-feature"><code>transition</code> tool (non-terminal)</td><td class="c-behavior"><code>buildTransitionTool</code> enum = only <code>rules[].next</code>; <code>validateTransition</code> rejects targets outside rules; <code>processTransitionCalls</code> records lessons</td><td class="c-source"><code>agent-loop.ts (`buildTransitionTool`, `validateTransition`, `processTransitionCalls`)</code></td><td class="c-test">agent-loop.test.ts &quot;non-terminal transition (movement-to-movement) still works&quot;; &quot;transition({next_step:…})&quot;; &quot;lets transition/complete win even if it shares a batch with a repeated call&quot; (tool-loop)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap"><code>buildTransitionTool</code> / <code>validateTransition</code> exercised only indirectly via executeMovement; no unit test that an out-of-rules <code>next</code> is rejected in isolation</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-003</code></td><td class="c-area">Engine core</td><td class="c-feature"><code>complete</code> tool — status routing</td><td class="c-behavior">success→COMPLETE, aborted→ABORT, needs_user_input→ASK via <code>COMPLETE_STATUS_TO_NEXT</code> / <code>buildMovementResultFromComplete</code></td><td class="c-source"><code>agent-loop.ts (`buildCompleteTool`, `parseCompleteArgs`, `buildMovementResultFromComplete`)</code></td><td class="c-test">agent-loop.test.ts §7.1 &quot;success status…becomes movement output&quot;; &quot;aborted status routes via ABORT next&quot;; &quot;needs_user_input routes via ASK next&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-004</code></td><td class="c-area">Engine core</td><td class="c-feature"><code>complete</code> arg validation</td><td class="c-behavior">success requires non-empty result; aborted requires abort_reason; needs_user_input requires missing_info; invalid → forced retry (no accumulatedText fallback)</td><td class="c-source"><code>agent-loop.ts (`parseCompleteArgs`, `validateCompleteArgs`)</code></td><td class="c-test">agent-loop.test.ts §7.1 &quot;rejects success with empty result&quot;; &quot;rejects aborted without abort_reason&quot;; (needs_user_input/missing_info path covered via routing test)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No explicit test that <code>needs_user_input</code> with empty <code>missing_info</code> is rejected (validateCompleteArgs branch); covered for success/aborted only</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-005</code></td><td class="c-area">Engine core</td><td class="c-feature">Multiple <code>complete</code> precedence</td><td class="c-behavior">0 completes → continue; &gt;1 conflicting args → retry; &gt;1 identical → first used</td><td class="c-source"><code>agent-loop.ts (complete dedup logic ~L1503-1535)</code></td><td class="c-test">agent-loop.test.ts §7.2 &quot;invalid native complete forces retry&quot;; &quot;two native completes with conflicting args → retry&quot;; &quot;two native completes with identical args → first one used&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-006</code></td><td class="c-area">Engine core</td><td class="c-feature">Conversation-history integrity on retry</td><td class="c-behavior">Every <code>tool_use</code> id gets a matching <code>tool_result</code> even when complete is rejected</td><td class="c-source"><code>agent-loop.ts (retry path)</code></td><td class="c-test">agent-loop.test.ts §7.7 &quot;all tool_use ids get a tool_result on retry&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-007</code></td><td class="c-area">Engine core</td><td class="c-feature">Tool-call loop detection (intra-movement)</td><td class="c-behavior">Identical tool-call batch repeated ≥ <code>maxToolLoopRepeats</code> (default 5) → ABORT; counter resets on arg change</td><td class="c-source"><code>agent-loop.ts (`DEFAULT_MAX_TOOL_LOOP_REPEATS`, `buildToolLoopAbortMessage`, consecutiveToolRepeats)</code></td><td class="c-test">agent-loop.tool-loop.test.ts (5 tests: limit abort, custom limit, under-limit, arg-change reset, transition wins)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-008</code></td><td class="c-area">Engine core</td><td class="c-feature">Loop-watchdog one-shot warning</td><td class="c-behavior">At <code>maxToolLoopRepeats-2</code> (min 2) inject a single <code>[loop watchdog]</code> reminder before aborting</td><td class="c-source"><code>agent-loop.ts (`warnAt`, `toolLoopWarned`, `injectLoopWarning`)</code></td><td class="c-test">(implicit in tool-loop tests' under-limit path)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No assertion that the watchdog warning message is actually injected exactly once before abort</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-009</code></td><td class="c-area">Engine core</td><td class="c-feature">Max-iterations abort</td><td class="c-behavior">Movement exceeding <code>safety.maxIterations</code> aborts with <code>buildMaxIterationsAbortMessage</code></td><td class="c-source"><code>agent-loop.ts (`buildMaxIterationsAbortMessage`)</code></td><td class="c-test">agent-loop.test.ts &quot;aborts when maxIterations is exceeded&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-010</code></td><td class="c-area">Engine core</td><td class="c-feature">Text-only-response handling</td><td class="c-behavior">NON-terminal movement aborts after N text-only turns; STRICTLY-terminal (defaultNext COMPLETE, no rules) salvages prose as completion; counter resets on tool use</td><td class="c-source"><code>agent-loop.ts (`TEXT_ONLY_REMIND_EMPTY`, textOnlyOutcome)</code></td><td class="c-test">agent-loop.test.ts &quot;aborts after text-only responses in a NON-terminal movement&quot;; &quot;salvages text-only prose…STRICTLY-terminal&quot;; &quot;does NOT salvage…still has rules&quot;; &quot;resets text-only counter when tool calls happen&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-011</code></td><td class="c-area">Engine core</td><td class="c-feature">Context-overflow forced transition (runtime usage)</td><td class="c-behavior">ContextManager threshold 95% → <code>force_transition</code> builds result via <code>movement.defaultNext</code>; falls to ABORT when defaultNext is terminal/absent</td><td class="c-source"><code>agent-loop.ts (`buildContextOverflowResult`, `buildForceTransitionResult`)</code></td><td class="c-test">agent-loop.test.ts &quot;triggers force_transition when context manager signals exhaustion&quot;; &quot;fires onContextAction callback when context threshold crossed&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-012</code></td><td class="c-area">Engine core</td><td class="c-feature">Oversized initial prompt guard</td><td class="c-behavior">Prompt over budget pre-send → dedup→compact→summarize; aborts/force-transitions when still oversized</td><td class="c-source"><code>agent-loop.ts + context/prompt-guard.ts (`guardPromptBeforeSend`)</code></td><td class="c-test">agent-loop.test.ts &quot;aborts when initial prompt is oversized and defaultNext is terminal&quot;; &quot;falls back to ABORT when oversized prompt has no defaultNext&quot;; prompt-guard.test.ts (full stage matrix)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-013</code></td><td class="c-area">Engine core</td><td class="c-feature">Cancel signal handling</td><td class="c-behavior">Already-aborted cancelSignal returns ABORT immediately; mid-movement cancel returns ABORT &quot;cancelled&quot;</td><td class="c-source"><code>agent-loop.ts (cancelSignal checks)</code></td><td class="c-test">agent-loop.test.ts &quot;returns ABORT immediately when cancelSignal is already aborted&quot;</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Mid-iteration cancel (after work started) not asserted at agent-loop level; only the pre-loop case</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-014</code></td><td class="c-area">Engine core</td><td class="c-feature">ContextManager threshold ladder</td><td class="c-behavior">70%→warn, 85%→prompt, 95%→force_transition; each fires once; isExhausted ≥0.99</td><td class="c-source"><code>context-manager.ts (`ContextManager`)</code></td><td class="c-test">context-manager.test.ts (13 tests: each threshold, fire-once, defaults, limitTokens, isExhausted, hasUsageData, char fallback)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-015</code></td><td class="c-area">Engine core</td><td class="c-feature">Ollama context-limit autodetect</td><td class="c-behavior"><code>fetchOllamaContextLimit</code> prefers num_ctx &gt; model_info.context_length; llama.cpp /props fallback; default on failure</td><td class="c-source"><code>context-manager.ts</code></td><td class="c-test">context-manager.test.ts (5 tests incl. num_ctx precedence, /props fallback, n_ctx absent)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-016</code></td><td class="c-area">Engine core</td><td class="c-feature">Tool catalog injection into system prompt</td><td class="c-behavior"><code>buildSystemPrompt</code> / <code>buildGlobalPreamble</code> auto-inject available-tools list + 1-line summaries; script section gated on Bash presence</td><td class="c-source"><code>agent-loop.ts (`buildGlobalPreamble`, `buildSystemPrompt`)</code></td><td class="c-test">agent-loop.test.ts &quot;injects the script section when Bash is among the presented tools&quot;; &quot;omits…when Bash is not presented&quot;; preamble byte-stability tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-017</code></td><td class="c-area">Engine core</td><td class="c-feature">Preamble/guidance split (job-stable vs movement)</td><td class="c-behavior"><code>buildGlobalPreamble</code> byte-identical across movements; <code>buildMovementGuidance</code> carries persona/instruction/transitions</td><td class="c-source"><code>agent-loop.ts</code></td><td class="c-test">agent-loop.test.ts &quot;preamble is byte-identical across two different movements&quot;; &quot;guidance carries movement-specific instruction, persona, transitions&quot;; &quot;buildSystemPrompt wrapper still contains both halves&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-018</code></td><td class="c-area">Engine core</td><td class="c-feature">Progressive pressure (intra-movement revisit)</td><td class="c-behavior">visitCount==2 → caution; &gt;=3 → warning injected into guidance</td><td class="c-source"><code>agent-loop.ts (`buildMovementGuidance` L672)</code></td><td class="c-test">agent-loop.test.ts &quot;guidance injects progressive-pressure warning on revisit&quot;</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Only the revisit-warning presence is asserted; the visitCount==2 (caution) vs &gt;=3 (warning) distinction is not separately tested</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-019</code></td><td class="c-area">Engine core</td><td class="c-feature">Checklist watchdog</td><td class="c-behavior">One-shot reminder after 5 iterations with no checklist tool; suppressed if CreateChecklist used early</td><td class="c-source"><code>agent-loop.ts</code></td><td class="c-test">agent-loop.test.ts &quot;injects a one-shot reminder after 5 iterations&quot;; &quot;does NOT fire when CreateChecklist is called early&quot;; traceability &quot;watchdog_fire&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-020</code></td><td class="c-area">Engine core</td><td class="c-feature">Cross-movement Read cache</td><td class="c-behavior">Cacheable read served to later movement; errors not cached; non-allowlisted tools (Bash) skipped</td><td class="c-source"><code>agent-loop.ts + context/tool-result-cache.ts</code></td><td class="c-test">agent-loop.test.ts &quot;returns a cached Read result…&quot;; &quot;does not cache error results&quot;; &quot;skips caching tools outside the cacheable allowlist&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-021</code></td><td class="c-area">Engine core</td><td class="c-feature">Cache invalidation</td><td class="c-behavior">Edit/Write invalidate affected path; Grep entries all-evicted on edit; Bash conservatively invalidates all file entries; no invalidate on tool error</td><td class="c-source"><code>agent-loop.ts + context/invalidation.ts + tool-result-cache.ts</code></td><td class="c-test">agent-loop.test.ts Phase 2/4 (7 tests); invalidation.test.ts (5); tool-result-cache.test.ts (28)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-022</code></td><td class="c-area">Engine core</td><td class="c-feature">Cache key construction</td><td class="c-behavior">Deterministic v1-prefixed keys for Read/Grep/Glob/WebFetch/Office; URL scheme/host normalization; workspace isolation</td><td class="c-source"><code>context/cache-key.ts</code></td><td class="c-test">cache-key.test.ts (25); tool-result-cache.test.ts key tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-023</code></td><td class="c-area">Engine core</td><td class="c-feature">File-read dedup</td><td class="c-behavior">Older Reads of same file replaced with placeholder, most-recent kept; ignores non-Read tools; idempotent</td><td class="c-source"><code>context/file-read-dedup.ts</code></td><td class="c-test">file-read-dedup.test.ts (10)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-024</code></td><td class="c-area">Engine core</td><td class="c-feature">History summarization</td><td class="c-behavior"><code>summarizeHistory</code> replaces middle turns with summary, preserves tail; LLM-failure → summarized=false; update-mode with prior summary</td><td class="c-source"><code>context/history-compactor.ts</code></td><td class="c-test">history-compactor.test.ts (21: splitIntoTurns, buildSummaryPrompt, selectTailTurnStartIndex, summarizeHistory, summarizeForceTransition)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-025</code></td><td class="c-area">Engine core</td><td class="c-feature">Token estimation</td><td class="c-behavior">char→token by script class (ASCII/CJK/other), per-message overhead, image budget, tool serialization</td><td class="c-source"><code>context/token-estimate.ts</code></td><td class="c-test">token-estimate.test.ts (23)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-026</code></td><td class="c-area">Engine core</td><td class="c-feature">Prompt-guard staged compaction</td><td class="c-behavior">dedup→compact→summarize ladder; reserve-cap headroom; stage-3 skipped when disabled/no isolated LLM; ok:false when all fail</td><td class="c-source"><code>context/prompt-guard.ts</code></td><td class="c-test">prompt-guard.test.ts (27)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-027</code></td><td class="c-area">Engine core</td><td class="c-feature">Conversation class (seed/enter/persist)</td><td class="c-behavior">seed order (preamble+guidance+task); enterMovement appends guidance w/o reset; flush/loadFrom round-trip; rewrite truncates</td><td class="c-source"><code>context/conversation.ts</code></td><td class="c-test">conversation.test.ts (in-memory + persistence, 8)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-028</code></td><td class="c-area">Engine core</td><td class="c-feature">Conversation.replayableTurns</td><td class="c-behavior">Strips system + control-flow tool_calls (complete/transition) + dangling/orphan tool messages; keeps valid pairs</td><td class="c-source"><code>context/conversation.ts</code></td><td class="c-test">conversation.test.ts (7 replayableTurns cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-029</code></td><td class="c-area">Engine core</td><td class="c-feature">Conversation.seedContinuation / hasReplayableTranscript</td><td class="c-behavior">Builds [preamble,guidance,...priorTurns,user] &amp; rewrites transcript; hasReplayableTranscript guards undefined/missing/system-only</td><td class="c-source"><code>context/conversation.ts</code></td><td class="c-test">conversation.test.ts (seedContinuation + 4 hasReplayableTranscript)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-030</code></td><td class="c-area">Engine core</td><td class="c-feature">Continuation seed in executeMovement</td><td class="c-behavior">priorTurns replayed &amp; handoff block suppressed; plain seed when absent</td><td class="c-source"><code>agent-loop.ts</code></td><td class="c-test">agent-loop.test.ts &quot;replays priorTurns and suppresses handoff block&quot;; &quot;without priorTurns, uses plain seed&quot;; &quot;with empty priorTurns array, behaves like plain seed&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-031</code></td><td class="c-area">Engine core</td><td class="c-feature">Atomic JSON persistence</td><td class="c-behavior">writeAtomicJson (tmp+rename, mkdir parents); readSafeJson returns missing/corrupt/wrong-version; quarantineCorruptFile</td><td class="c-source"><code>context/atomic-json.ts</code></td><td class="c-test">atomic-json.test.ts (11)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-032</code></td><td class="c-area">Engine core</td><td class="c-feature">Workspace path normalization</td><td class="c-behavior">Rejects absolute/backslash/drive/UNC/NUL/empty; canonicalizes <code>..</code>/<code>.</code>; prefixWorkspacePath join with traversal guard</td><td class="c-source"><code>context/path-normalize.ts</code></td><td class="c-test">path-normalize.test.ts (15)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-033</code></td><td class="c-area">Engine core</td><td class="c-feature">Piece loop guards (cross-movement)</td><td class="c-behavior"><code>enforceLoopGuards</code>: consecutive revisits &gt; max_consecutive_revisits (default 4) → abort; counters reset on movement change</td><td class="c-source"><code>piece-runner.ts (`enforceLoopGuards`)</code></td><td class="c-test">piece-runner.test.ts &quot;aborts when loop detection fires due to consecutive revisits&quot;</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Per-movement <code>max_consecutive_revisits</code> override and the counter-reset-on-movement-change branch not separately asserted</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-034</code></td><td class="c-area">Engine core</td><td class="c-feature">ASK limit fallback</td><td class="c-behavior">ASK result resolves <code>default_next</code> or first non-self/non-ASK/non-ABORT rule; aborts when no fallback; resolves COMPLETE default_next via ASK-limit fallback</td><td class="c-source"><code>piece-runner.ts (result.next==='ASK' branch)</code></td><td class="c-test">piece-runner.test.ts &quot;falls back to default_next when ASK limit is reached&quot;; &quot;aborts when ASK limit reached and no fallback&quot;; &quot;resolves a COMPLETE default_next reached via the ASK-limit fallback&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-035</code></td><td class="c-area">Engine core</td><td class="c-feature">WAIT_SUBTASKS pause</td><td class="c-behavior">result.next==='WAIT_SUBTASKS' pauses piece run (waiting_subtasks)</td><td class="c-source"><code>piece-runner.ts (L1144)</code></td><td class="c-test">piece-runner.test.ts &quot;does NOT write snapshot on waiting_subtasks (transient pause)&quot;</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">The pause/resume return contract is only asserted via the snapshot-suppression side effect; no direct test of the waiting_subtasks PieceRunResult shape</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-036</code></td><td class="c-area">Engine core</td><td class="c-feature">SpawnSubTask depth-limit skip</td><td class="c-behavior">Movement requiring SpawnSubTask skipped at depth limit (incl. via shared_tools); resolves COMPLETE default_next on skip</td><td class="c-source"><code>piece-runner.ts (`pieceUsesSpawn`, spawn skip)</code></td><td class="c-test">piece-runner.test.ts &quot;resolves a COMPLETE default_next when a SpawnSubTask movement is skipped at the depth limit&quot;; &quot;skips a SpawnSubTask movement at the depth limit even when…shared_tools&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Engine core"><td class="c-id"><code>ENG-037</code></td><td class="c-area">Engine core</td><td class="c-feature">WAITING_HUMAN_BROWSER / _TOOL_REQUEST pauses</td><td class="c-behavior">Non-ASK human-wait sentinels pause the run</td><td class="c-source"><code>piece-runner.ts (L1159, L1176)</code></td><td class="c-test">(none found)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">No test exercises the WAITING_HUMAN_BROWSER or WAITING_HUMAN_TOOL_REQUEST branches</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-038</code></td><td class="c-area">Engine core</td><td class="c-feature">Review-feedback carry-forward</td><td class="c-behavior">verify movement output carried into later execute/process/plan/analyze; truncated per/combined caps; git status+diff appended after verify</td><td class="c-source"><code>piece-runner.ts (`buildInstructionWithFeedback`, `shouldCarryReviewFeedback`, truncate/trim)</code></td><td class="c-test">piece-runner.test.ts &quot;carries cumulative verify feedback into later execute/analyze movements&quot;; &quot;appends safe git status and diff context after verify loops&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-039</code></td><td class="c-area">Engine core</td><td class="c-feature">Lessons accumulation &amp; injection</td><td class="c-behavior">transition/complete lessons accumulated, capped (MAX_LESSONS_LENGTH 2000), injected into next movement; written to lessons.jsonl</td><td class="c-source"><code>piece-runner.ts (`buildLessonsContext`, `writeLessonLog`, lessonsAccumulator)</code></td><td class="c-test">(no direct test of buildLessonsContext/writeLessonLog; snapshot tests reference lessons)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Lessons capture appears in cancel-snapshot tests but the injection-into-next-movement and 2000-char trim logic is not directly asserted; writeLessonLog untested</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-040</code></td><td class="c-area">Engine core</td><td class="c-feature">loadPiece terminal-rule validation</td><td class="c-behavior">rejects rules[].next ∈ {COMPLETE,ABORT,ASK}; accepts default_next COMPLETE &amp; WAIT_SUBTASKS sentinel; all bundled pieces valid</td><td class="c-source"><code>piece-runner.ts (`validatePieceDef`, `loadPiece`)</code></td><td class="c-test">piece-runner.test.ts (Phase 6b: 6 tests incl. all bundled pieces load)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-041</code></td><td class="c-area">Engine core</td><td class="c-feature">loadPiece multi-dir resolution</td><td class="c-behavior">custom dir(s) win over builtin; first dir wins on dup name; string|string[] forms</td><td class="c-source"><code>piece-runner.ts (`loadPiece`)</code></td><td class="c-test">piece-runner.test.ts &quot;resolves from a list of custom dirs&quot;; &quot;first dir wins&quot;; &quot;string form still works&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-042</code></td><td class="c-area">Engine core</td><td class="c-feature">normalizeRequiredMcp</td><td class="c-behavior">retains valid slugs, drops invalid, undefined when absent, coerces non-array to []</td><td class="c-source"><code>piece-runner.ts</code></td><td class="c-test">piece-runner.test.ts (4 required_mcp tests)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-043</code></td><td class="c-area">Engine core</td><td class="c-feature">normalizeSharedTools / mergeToolNames</td><td class="c-behavior">shared_tools sanitized; mergeToolNames unions shared+movement first-seen order, drops dups/non-strings</td><td class="c-source"><code>piece-runner.ts</code></td><td class="c-test">piece-runner.test.ts (shared_tools 4 + mergeToolNames 3)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-044</code></td><td class="c-area">Engine core</td><td class="c-feature">validateAllowedSshConnections</td><td class="c-behavior">validates id format / wildcard / empty-deny; tolerates missing allowlist (A4 workspace-policy gating); reports offenders</td><td class="c-source"><code>piece-runner.ts</code></td><td class="c-test">piece-runner.test.ts (allowed_ssh_connections 15 + loader-tolerance 1)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-045</code></td><td class="c-area">Engine core</td><td class="c-feature">Workspace tool-policy resolution &amp; injection</td><td class="c-behavior">resolveWorkspaceTools (safe default, Bash/browser opt-in); runPiece injects workspaceTools overriding movement.allowed_tools; grantedTools unioned</td><td class="c-source"><code>piece-runner.ts (runPiece) + workspace-tool-policy.ts</code></td><td class="c-test">piece-runner.test.ts (resolveWorkspaceTools 5 + runPiece injection 4); workspace-tool-policy.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-046</code></td><td class="c-area">Engine core</td><td class="c-feature">runPiece max_movements default</td><td class="c-behavior">iterates when max_movements missing / 0 / negative</td><td class="c-source"><code>piece-runner.ts (runPiece)</code></td><td class="c-test">piece-runner.test.ts &quot;still iterates when piece.max_movements is missing&quot;; &quot;…is 0 or negative&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-047</code></td><td class="c-area">Engine core</td><td class="c-feature">buildFollowupNotice</td><td class="c-behavior">detects follow-up when output/ or subtasks/ has non-hidden files; ignores dotfiles; empty for fresh ws</td><td class="c-source"><code>piece-runner.ts</code></td><td class="c-test">piece-runner.test.ts (5 buildFollowupNotice tests)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-048</code></td><td class="c-area">Engine core</td><td class="c-feature">Cancel/terminal memory snapshots</td><td class="c-behavior">snapshot+meta-event on cancelled (pre/mid movement) and aborted; NOT on success or waiting_subtasks; v2 captures finalOutput/history/lessons w/ truncation</td><td class="c-source"><code>piece-runner.ts</code></td><td class="c-test">piece-runner.test.ts (7 snapshot tests)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-049</code></td><td class="c-area">Engine core</td><td class="c-feature">Conversation continuity end-to-end</td><td class="c-behavior">movement 2 sees movement 1 history; transcript.jsonl written; continuation job replays prior turns &amp; suppresses LIMIT-1 handoff</td><td class="c-source"><code>piece-runner.ts + conversation.ts</code></td><td class="c-test">piece-runner.conversation.test.ts (2)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-050</code></td><td class="c-area">Engine core</td><td class="c-feature">Piece classification prompt</td><td class="c-behavior"><code>buildClassificationPrompt</code> includes task+descriptions+files; biases toward chat; routes workspace-app</td><td class="c-source"><code>piece-classifier.ts</code></td><td class="c-test">piece-classifier.test.ts (buildClassificationPrompt 4)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-051</code></td><td class="c-area">Engine core</td><td class="c-feature">Classification response parsing</td><td class="c-behavior"><code>parseClassificationResponse</code> extracts valid name, handles noise, prefers longest match, null on invalid/empty</td><td class="c-source"><code>piece-classifier.ts</code></td><td class="c-test">piece-classifier.test.ts (parseClassificationResponse 5)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Engine core"><td class="c-id"><code>ENG-052</code></td><td class="c-area">Engine core</td><td class="c-feature"><code>classifyPiece</code> (async LLM orchestration)</td><td class="c-behavior">Builds prompt, calls LLM, parses + falls back to default piece</td><td class="c-source"><code>piece-classifier.ts (`classifyPiece`)</code></td><td class="c-test">(none)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">The end-to-end classify path (LLM call wiring, fallback when parse returns null, error handling) is untested; only its two pure helpers are</td></tr>
<tr data-cov="NONE" data-area="Engine core"><td class="c-id"><code>ENG-053</code></td><td class="c-area">Engine core</td><td class="c-feature">loadAllPieceTriggers</td><td class="c-behavior">Loads triggers/keywords from all pieces across dirs; custom wins on dup name; skips invalid pieces</td><td class="c-source"><code>piece-runner.ts (`loadAllPieceTriggers`)</code></td><td class="c-test">(none)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">No test; dir-precedence and skip-on-load-error branches uncovered</td></tr>
<tr data-cov="PARTIAL" data-area="Engine core"><td class="c-id"><code>ENG-054</code></td><td class="c-area">Engine core</td><td class="c-feature">buildChecklistContext</td><td class="c-behavior">Builds checklist context block from logs root</td><td class="c-source"><code>piece-runner.ts</code></td><td class="c-test">(referenced in piece-runner.test.ts import only; no describe block)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Symbol imported in test file but no behavior assertion targets it specifically</td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-055</code></td><td class="c-area">Engine core</td><td class="c-feature">buildLocalConversationContext</td><td class="c-behavior">Renders current instruction + recent-conversation + workspace files; interjection precedence; truncation; omitRecentConversation flag</td><td class="c-source"><code>local-context.ts</code></td><td class="c-test">local-context.test.ts (7)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-056</code></td><td class="c-area">Engine core</td><td class="c-feature">PieceCatalog caching/layering</td><td class="c-behavior">Built-ins + user pieces (custom wins); TTL cache; invalidate(userId); per-folder cache isolation</td><td class="c-source"><code>piece-catalog.ts</code></td><td class="c-test">piece-catalog.test.ts (7)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-057</code></td><td class="c-area">Engine core</td><td class="c-feature">stripThinkingTokens</td><td class="c-behavior">Strips &lt;think&gt;/&lt;|thinking|&gt;/gemma channel blocks; leaves unclosed intact; preserves unicode</td><td class="c-source"><code>strip-thinking.ts</code></td><td class="c-test">strip-thinking.test.ts (11) + agent-loop.test.ts (6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-058</code></td><td class="c-area">Engine core</td><td class="c-feature">prompt-coach scoring</td><td class="c-behavior">buildCoachMessages (source inclusion + truncation); normalizeCoachResult (clamp/defaults); runPromptCoach (normalize + error propagation)</td><td class="c-source"><code>prompt-coach.ts</code></td><td class="c-test">prompt-coach.test.ts (9)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-059</code></td><td class="c-area">Engine core</td><td class="c-feature">Console screen injection</td><td class="c-behavior">Injects SSH console screen each iteration when SshConsole* allowed + session exists; guards on missing taskId / no session / non-console piece; tail truncation</td><td class="c-source"><code>agent-loop.ts (buildSystemPrompt)</code></td><td class="c-test">agent-loop.test.ts (5 console) + agent-loop-console.test.ts (4)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-060</code></td><td class="c-area">Engine core</td><td class="c-feature">Handoff block construction</td><td class="c-behavior">Static Continue block always; dynamic block only with handoffContext; null prevResult handled; long prevResult truncated head+tail</td><td class="c-source"><code>agent-loop.ts (buildSystemPrompt)</code></td><td class="c-test">agent-loop.test.ts (5 handoff tests)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-061</code></td><td class="c-area">Engine core</td><td class="c-feature">Per-user AGENTS.md / memory / working-dir injection</td><td class="c-behavior">Injects AGENTS.md + persistence protocol + Working Directory + approach/error-recovery sections, gated on userId/workspacePath</td><td class="c-source"><code>agent-loop.ts (buildSystemPrompt)</code></td><td class="c-test">agent-loop.user-agents.test.ts (9)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-062</code></td><td class="c-area">Engine core</td><td class="c-feature">Existing-workspace-files injection</td><td class="c-behavior">Injects existing input-files section when present, omits when empty</td><td class="c-source"><code>agent-loop.ts (buildSystemPrompt)</code></td><td class="c-test">agent-loop.test.ts (2)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-063</code></td><td class="c-area">Engine core</td><td class="c-feature">Traceability event emission</td><td class="c-behavior">movement_start/tool_call/result/movement_complete; cache_set/hit/invalidate; watchdog_fire; run_start/complete; followup_detected; shared runId</td><td class="c-source"><code>agent-loop.ts + piece-runner.ts</code></td><td class="c-test">agent-loop.test.ts (T-1, 5) + piece-runner.test.ts (T-2, 2)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Engine core"><td class="c-id"><code>ENG-064</code></td><td class="c-area">Engine core</td><td class="c-feature">ownerId/userId defaulting</td><td class="c-behavior">Defaults to 'local'/synthetic when unowned; preserves real owner in auth mode</td><td class="c-source"><code>piece-runner.ts (runPiece)</code></td><td class="c-test">piece-runner.test.ts &quot;defaults ownerId and userId to…&quot;; &quot;preserves a real ownerId/userId&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Engine core"><td class="c-id"><code>ENG-065</code></td><td class="c-area">Engine core</td><td class="c-feature">Mission char-budget trimming</td><td class="c-behavior"><code>buildGlobalPreamble</code> trims mission text when total exceeds MISSION_TOTAL_CHAR_BUDGET</td><td class="c-source"><code>agent-loop.ts (L428 overflow trim)</code></td><td class="c-test">(none)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">The mission/preamble char-budget overflow trim is not asserted by any test</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-001</code></td><td class="c-area">Tools</td><td class="c-feature">Read</td><td class="c-behavior">Read file slice (offset/limit); delegates binary detection; rejects directories (EISDIR), enforces readonly/</td><td class="c-source"><code></code></td><td class="c-test">core.test.ts (88 it: binary detector, readonly/ enforce, symlink escape)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">EISDIR/dir-reject path (MEMORY: prior crash) not explicitly named in describes — verify a dir-reject test exists</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-002</code></td><td class="c-area">Tools</td><td class="c-feature">Write</td><td class="c-behavior">Write to workspace; <code>assertWritable</code> blocks readonly/ + outside-workspace</td><td class="c-source"><code></code></td><td class="c-test">core.test.ts (readonly/ enforcement, resolveAndGuard symlink escape)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-003</code></td><td class="c-area">Tools</td><td class="c-feature">Edit</td><td class="c-behavior">Exact-string replace; same write-guard path as Write</td><td class="c-source"><code></code></td><td class="c-test">core.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Edit-specific match-fail / replace_all behavior not clearly enumerated in describes</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-004</code></td><td class="c-area">Tools</td><td class="c-feature">Bash</td><td class="c-behavior">Shell exec; AbortSignal propagation, path-scope guard, install-pattern blocking, unrestricted mode, env scrub, history log</td><td class="c-source"><code></code></td><td class="c-test">core.test.ts (AbortSignal, checkBashPathScope, install rejection, bashUnrestricted, env scrub, history)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Strong coverage incl. cancel-traceability</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-005</code></td><td class="c-area">Tools</td><td class="c-feature">Glob</td><td class="c-behavior">Pattern file match</td><td class="c-source"><code></code></td><td class="c-test">core.test.ts (core tools)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Few dedicated Glob assertions; edge cases (no-match, ignore) unclear</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-006</code></td><td class="c-area">Tools</td><td class="c-feature">Grep</td><td class="c-behavior">Content search</td><td class="c-source"><code></code></td><td class="c-test">core.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Same — limited Grep-specific cases</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-007</code></td><td class="c-area">Tools</td><td class="c-feature">WebSearch</td><td class="c-behavior">SearXNG search w/ fallback + history; query sanitize</td><td class="c-source"><code></code></td><td class="c-test">web.test.ts (sanitizeQuery, searchViaSearxng, fallback history, parseSearchResultsFromText)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-008</code></td><td class="c-area">Tools</td><td class="c-feature">WebFetch</td><td class="c-behavior">Fetch URL → readability text; SSRF guard; persistent context mgmt</td><td class="c-source"><code></code></td><td class="c-test">web.test.ts (web tools, persistent context), shared/ssrf.test.ts, shared/readability.test.ts, web.binary.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">SSRF tested at shared layer; binary handling covered</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-009</code></td><td class="c-area">Tools</td><td class="c-feature">DownloadFile</td><td class="c-behavior">Download to input/ or source/; source-library index.jsonl; raw log-only</td><td class="c-source"><code></code></td><td class="c-test">web.test.ts (source-library routing 2 it), raw-save</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Size-limit / large-file / redirect handling on download not asserted</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-010</code></td><td class="c-area">Tools</td><td class="c-feature">ReadImage</td><td class="c-behavior">Read image → vision; usage tracking</td><td class="c-source"><code></code></td><td class="c-test">image.test.ts (13 it), image.vision-usage.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-011</code></td><td class="c-area">Tools</td><td class="c-feature">AnnotateImage</td><td class="c-behavior">Draw annotations on image</td><td class="c-source"><code></code></td><td class="c-test">image.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Annotation rendering correctness lightly covered</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-012</code></td><td class="c-area">Tools</td><td class="c-feature">ReadExcel</td><td class="c-behavior">Parse xlsx/xls/xlsm/xlsb; size limit 10MB; format-mismatch + CFB/HTML/CSV disguise rejection; optional styles</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts (format rejection 8 it, styles), excel-styles.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">**Size-limit (&gt;10MB) enforcement path not tested** — only format mismatch</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-013</code></td><td class="c-area">Tools</td><td class="c-feature">ReadDocx</td><td class="c-behavior">Parse docx/docm; size limit 10MB</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Size-limit path untested; happy-path light</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-014</code></td><td class="c-area">Tools</td><td class="c-feature">ReadPdf</td><td class="c-behavior">Extract PDF text; query/grep mode (regex, case-insensitive, no-match, empty-query); size limit 10MB; OOXML-as-pdf rejection</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts (ReadPdf query mode 5 it, format rejection)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Size-limit path untested</td></tr>
<tr data-cov="NONE" data-area="Tools"><td class="c-id"><code>TOOL-015</code></td><td class="c-area">Tools</td><td class="c-feature">ReadPPTX</td><td class="c-behavior">Parse pptx/pptm; slide order resolution; notes; **ZIP-bomb detection (uncompressed &gt;200MB)**; size limit 50MB</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">**ZIP-bomb detection AND PPTX size-limit have NO test** — highest-risk office gap</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-016</code></td><td class="c-area">Tools</td><td class="c-feature">ReadMsg</td><td class="c-behavior">Parse Outlook .msg</td><td class="c-source"><code></code></td><td class="c-test">msg.test.ts (41 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-017</code></td><td class="c-area">Tools</td><td class="c-feature">SplitExcelSheets</td><td class="c-behavior">Split workbook into per-sheet files</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Split correctness lightly covered</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-018</code></td><td class="c-area">Tools</td><td class="c-feature">SplitDocxSections</td><td class="c-behavior">Split docx into section files</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Same</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-019</code></td><td class="c-area">Tools</td><td class="c-feature">PdfToImages</td><td class="c-behavior">Render PDF pages to images; edit-not-allowed error, missing-file error, invalid page_range error</td><td class="c-source"><code></code></td><td class="c-test">office.test.ts (3 error-path it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Error paths covered</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-020</code></td><td class="c-area">Tools</td><td class="c-feature">SQLite</td><td class="c-behavior">Run SQL on workspace .db; SELECT happy path, write guards (editAllowed), always-blocked DDL, PRAGMA, errors, path-traversal guard, large result sets</td><td class="c-source"><code></code></td><td class="c-test">data.test.ts (25 it across 9 describe)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Strong — guards + traversal + DDL block</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-021</code></td><td class="c-area">Tools</td><td class="c-feature">BatchReviewTextWithLLM</td><td class="c-behavior">Per-file isolated LLM review; output-path prefix enforcement</td><td class="c-source"><code></code></td><td class="c-test">review.test.ts (3 it)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Only 3 it; LLM-failure / partial-file handling not covered</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-022</code></td><td class="c-area">Tools</td><td class="c-feature">MergeReviewedResults</td><td class="c-behavior">Merge reviewed JSON → markdown; rejects outputs outside prefixes</td><td class="c-source"><code></code></td><td class="c-test">review.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Merge edge cases (malformed JSON) untested</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-023</code></td><td class="c-area">Tools</td><td class="c-feature">BrowseWeb</td><td class="c-behavior">Playwright browse + actions mode; URL validation, SSRF/localhost block, file-URL workspace containment, traversal reject, auth-expiry, download filename sanitize, unique-path, recording, source persist</td><td class="c-source"><code></code></td><td class="c-test">browser.test.ts (36 it), browser.runpageactions.test.ts, browser-frame-chain.e2e.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Validation/SSRF/recording very strong; live page-action correctness is e2e-gated (env-dependent)</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-024</code></td><td class="c-area">Tools</td><td class="c-feature">SpawnSubTask</td><td class="c-behavior">Spawn parallel subtask job</td><td class="c-source"><code></code></td><td class="c-test">orchestration.test.ts (8 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-025</code></td><td class="c-area">Tools</td><td class="c-feature">XSearch</td><td class="c-behavior">X/Twitter search</td><td class="c-source"><code></code></td><td class="c-test">x.test.ts (18 it)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Live transaction-id breakage (MEMORY) means runtime failures not covered by unit tests</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-026</code></td><td class="c-area">Tools</td><td class="c-feature">XUserPosts</td><td class="c-behavior">X user timeline posts</td><td class="c-source"><code></code></td><td class="c-test">x.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Same upstream breakage caveat</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-027</code></td><td class="c-area">Tools</td><td class="c-feature">XPostDetail</td><td class="c-behavior">Single X post detail</td><td class="c-source"><code></code></td><td class="c-test">x.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Same</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-028</code></td><td class="c-area">Tools</td><td class="c-feature">XFetchCardMedia</td><td class="c-behavior">Fetch X card media</td><td class="c-source"><code></code></td><td class="c-test">x.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Likely thin coverage</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-029</code></td><td class="c-area">Tools</td><td class="c-feature">XTimeline</td><td class="c-behavior">X home/timeline</td><td class="c-source"><code></code></td><td class="c-test">x.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Likely thin coverage</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-030</code></td><td class="c-area">Tools</td><td class="c-feature">SearchPlaces</td><td class="c-behavior">Maps place search</td><td class="c-source"><code></code></td><td class="c-test">maps.test.ts (27 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-031</code></td><td class="c-area">Tools</td><td class="c-feature">GetDirections</td><td class="c-behavior">Maps routing</td><td class="c-source"><code></code></td><td class="c-test">maps.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-032</code></td><td class="c-area">Tools</td><td class="c-feature">ReverseGeocode</td><td class="c-behavior">Coords → address</td><td class="c-source"><code></code></td><td class="c-test">maps.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-033</code></td><td class="c-area">Tools</td><td class="c-feature">GetYouTubeTranscript</td><td class="c-behavior">Fetch YouTube transcript</td><td class="c-source"><code></code></td><td class="c-test">youtube.test.ts (20 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-034</code></td><td class="c-area">Tools</td><td class="c-feature">SearchYouTube</td><td class="c-behavior">YouTube search</td><td class="c-source"><code></code></td><td class="c-test">youtube.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-035</code></td><td class="c-area">Tools</td><td class="c-feature">SearchAmazon</td><td class="c-behavior">Amazon product search</td><td class="c-source"><code></code></td><td class="c-test">amazon.test.ts (13 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-036</code></td><td class="c-area">Tools</td><td class="c-feature">TranscribeAudio</td><td class="c-behavior">Speech-to-text</td><td class="c-source"><code></code></td><td class="c-test">speech.test.ts (14 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-037</code></td><td class="c-area">Tools</td><td class="c-feature">CreateChecklist</td><td class="c-behavior">Create task checklist (META)</td><td class="c-source"><code></code></td><td class="c-test">checklist.test.ts (24 it incl. META_TOOL describe)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-038</code></td><td class="c-area">Tools</td><td class="c-feature">CheckItem</td><td class="c-behavior">Mark checklist item (META)</td><td class="c-source"><code></code></td><td class="c-test">checklist.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-039</code></td><td class="c-area">Tools</td><td class="c-feature">GetChecklist</td><td class="c-behavior">Read checklist (META)</td><td class="c-source"><code></code></td><td class="c-test">checklist.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-040</code></td><td class="c-area">Tools</td><td class="c-feature">ListPieces</td><td class="c-behavior">List available pieces</td><td class="c-source"><code></code></td><td class="c-test">pieces.test.ts (11 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-041</code></td><td class="c-area">Tools</td><td class="c-feature">GetPiece</td><td class="c-behavior">Read a piece YAML</td><td class="c-source"><code></code></td><td class="c-test">pieces.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-042</code></td><td class="c-area">Tools</td><td class="c-feature">CreatePiece</td><td class="c-behavior">Create custom piece (per-user fork)</td><td class="c-source"><code></code></td><td class="c-test">pieces.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Validation/lint reject paths (COMPLETE/ABORT in next) coverage unclear here</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>TOOL-043</code></td><td class="c-area">Tools</td><td class="c-feature">UpdatePiece</td><td class="c-behavior">Update custom piece</td><td class="c-source"><code></code></td><td class="c-test">pieces.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Same</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-044</code></td><td class="c-area">Tools</td><td class="c-feature">InstallSkill</td><td class="c-behavior">Install skill from registry (META)</td><td class="c-source"><code></code></td><td class="c-test">skills.test.ts (26 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-045</code></td><td class="c-area">Tools</td><td class="c-feature">ReadSkill</td><td class="c-behavior">Read skill content (META, always available)</td><td class="c-source"><code></code></td><td class="c-test">skills.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-046</code></td><td class="c-area">Tools</td><td class="c-feature">ListSkills</td><td class="c-behavior">List skills (META)</td><td class="c-source"><code></code></td><td class="c-test">skills.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>TOOL-047</code></td><td class="c-area">Tools</td><td class="c-feature">ReadToolDoc</td><td class="c-behavior">Read tool doc incl. MCP tools via cache (META, always available); name-required error, bad-MCP-name error</td><td class="c-source"><code></code></td><td class="c-test">docs.test.ts (10 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Tools"><td class="c-id"><code>XCUT-001</code></td><td class="c-area">Tools</td><td class="c-feature">Dynamic tool loading + dispatch order</td><td class="c-behavior">index.ts <code>tryLoadModule</code> lazy-imports each module; <code>executeToolInner</code> dispatches in fixed order (mcp → web → image → data → office → review → x → orchestration → browser → maps → … → skills); failed loads silently skipped</td><td class="c-source"><code></code></td><td class="c-test">index.test.ts (1 it: SshConsole catalog)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">**Dispatch order, first-match-wins, and silent-skip-on-load-failure are essentially untested** (single catalog test)</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>XCUT-002</code></td><td class="c-area">Tools</td><td class="c-feature">META_TOOLS always-injected at dispatch</td><td class="c-behavior">index.ts keeps a LARGER META_TOOLS list (incl. RequestTool, Mission, UserFolder, AppDocs, Skills) injected into getToolDefs regardless of movement allowed_tools</td><td class="c-source"><code></code></td><td class="c-test">(indirect via tool-categories.test.ts subset)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">index.ts's runtime META injection (20 names) not directly asserted; only tool-categories' SUBSET set is tested</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>XCUT-003</code></td><td class="c-area">Tools</td><td class="c-feature">tool-categories: name→category map</td><td class="c-behavior">first-write-wins map built from ALL_TOOL_DEFS('core') + MODULE_SPECS; memoised; listToolCategories; SENSITIVE_CATEGORIES={ssh,browser}; SENSITIVE_TOOLS={Bash}; META_TOOLS subset</td><td class="c-source"><code></code></td><td class="c-test">tool-categories.test.ts (12 it: META_TOOLS entries, sensitive sets, map)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>XCUT-004</code></td><td class="c-area">Tools</td><td class="c-feature">RequestTool flow</td><td class="c-behavior">META tool; classifies request as already_available / available_not_allowed(requested) / unknown(hard-error); name+reason required; interactive→pending approval, non-interactive→auto_denied; records via ctx.recordToolRequest</td><td class="c-source"><code></code></td><td class="c-test">tool-request.test.ts (9 it), db/repository.tool-requests.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">All 3 classification branches + interactive/auto-deny covered</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>XCUT-005</code></td><td class="c-area">Tools</td><td class="c-feature">workspace-tool-policy resolution</td><td class="c-behavior">resolveWorkspaceTools: safe categories default-on (minus disabledSafe), sensitive categories+tools opt-in only (enabledSensitive), META always unioned, Bash special-cased, <code>mcp__*</code> always carried; parseToolPolicy tolerant JSON</td><td class="c-source"><code></code></td><td class="c-test">workspace-tool-policy.test.ts, workspace-tool-policy.regression.test.ts, db/repository.tool-policy.test.ts, bridge/space-api.tool-policy.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Strong; regression test guards Bash toggle (MEMORY PR #653)</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>XCUT-006</code></td><td class="c-area">Tools</td><td class="c-feature">raw-save result logging</td><td class="c-behavior">saveRawData writes {logsRoot}/raw/&lt;tool&gt;-&lt;ts&gt;.txt + rawdata-history.jsonl for RAW_SAVE_TOOLS (web/x/youtube/amazon/speech/ms-learn); logRawDownload records DownloadFile path-only; errors swallowed to logger.warn</td><td class="c-source"><code></code></td><td class="c-test">raw-save.test.ts (5 it)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Helper, not a tool. Per-tool wiring (that each RAW_SAVE_TOOL actually triggers save) is not asserted end-to-end</td></tr>
<tr data-cov="PARTIAL" data-area="Tools"><td class="c-id"><code>XCUT-007</code></td><td class="c-area">Tools</td><td class="c-feature">structured-blocks</td><td class="c-behavior">Rich-UI structured-data save helper (no TOOL_DEFS)</td><td class="c-source"><code></code></td><td class="c-test">structured-blocks.test.ts (4 it)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Helper; basic coverage</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>XCUT-008</code></td><td class="c-area">Tools</td><td class="c-feature">binary-detect shared helper</td><td class="c-behavior">Magic-byte/binary detection used by Read + WebFetch</td><td class="c-source"><code></code></td><td class="c-test">binary-detect.test.ts (26 it)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Well covered; underpins Read/WebFetch binary handling</td></tr>
<tr data-cov="FULL" data-area="Tools"><td class="c-id"><code>XCUT-009</code></td><td class="c-area">Tools</td><td class="c-feature">conflict-guard (persistent workspace)</td><td class="c-behavior">Detects concurrent-edit conflicts in shared space</td><td class="c-source"><code></code></td><td class="c-test">conflict-guard.test.ts (9 it), core.test.ts (conflict detection)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-001</code></td><td class="c-area">Pieces</td><td class="c-feature">chat</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-classifier.test (as default-bias target); reflection/silent-fork &amp; applier.fuzz use 'chat'; piece-runner all-load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No test asserts the single-movement respond contract or its 43-tool allowlist; only used as a name fixture + load-sweep</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-002</code></td><td class="c-area">Pieces</td><td class="c-feature">general</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test (<code>loadPiece('general')</code> line 401, used in review-prompt/shared_tools tests); pieces-api uses 'general' fixture; load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Loaded &amp; used as a fixture for unrelated assertions (review prompts, SpawnSubTask skip), but its 4-movement decompose/verify loop is not asserted as a contract</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-003</code></td><td class="c-area">Pieces</td><td class="c-feature">research</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test <code>loadPiece('research')</code> (line 403, review-prompt structure assertion); load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Review-prompt/plan-aware structure asserted for it, but the dig/analyze/verify transition logic is not exercised</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-004</code></td><td class="c-area">Pieces</td><td class="c-feature">research-sub</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">load sweep only</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No direct test; only schema-load smoke. Spawn-from-parent linkage untested</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-005</code></td><td class="c-area">Pieces</td><td class="c-feature">data-process</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-classifier.test (longer-match-wins: data-process over general); load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Classifier disambiguation asserted; movement contract not</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-006</code></td><td class="c-area">Pieces</td><td class="c-feature">office-process</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test <code>loadPiece('office-process')</code> (line 402); piece-classifier fixture; load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Loaded for review-prompt assertion; 2-movement contract not exercised</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-007</code></td><td class="c-area">Pieces</td><td class="c-feature">slide</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-classifier.test (chat vs slide routing fixture); load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Used as classifier fixture name; movement/tool contract untested</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-008</code></td><td class="c-area">Pieces</td><td class="c-feature">brainstorming</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">load sweep only</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No direct test; schema-load smoke only</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-009</code></td><td class="c-area">Pieces</td><td class="c-feature">sns-research</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">load sweep only</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No direct test</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-010</code></td><td class="c-area">Pieces</td><td class="c-feature">x-ai-digest</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">load sweep only</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No direct test</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-011</code></td><td class="c-area">Pieces</td><td class="c-feature">piece-builder</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-classifier.test (workspace-app routed AWAY from piece-builder); load sweep</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Only the negative-routing case touches it; its own contract untested</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PIECE-012</code></td><td class="c-area">Pieces</td><td class="c-feature">workspace-app</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test dedicated movement-structure assertions (build→verify, default_next checks, lines 542-557); piece-classifier routing test (line 41-52); workspace-app E2E harness (separate subsystem)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Movement structure + default_next + classifier routing all asserted; strongest-covered piece</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PIECE-013</code></td><td class="c-area">Pieces</td><td class="c-feature">ssh-console</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test dedicated assertion (interact default_next: COMPLETE, line 563-572); tools/ssh-console.test; ssh allowed_ssh_connections validation tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Movement + ssh-connection allowlist contract asserted</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PIECE-014</code></td><td class="c-area">Pieces</td><td class="c-feature">ssh-ops</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test dedicated assertion (line 576+); ssh allowlist validation tests; load sweep</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Loaded + structurally asserted (execute/verify); ssh allowlist format validation covered. Lighter than ssh-console but real assertions exist</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PIECE-015</code></td><td class="c-area">Pieces</td><td class="c-feature">help</td><td class="c-behavior"></td><td class="c-source"><code></code></td><td class="c-test">load sweep only</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No direct test</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-001</code></td><td class="c-area">Pieces</td><td class="c-feature">loadPiece (custom-dir priority + builtin fallback)</td><td class="c-behavior">Resolves piece by name across custom dirs (string|string[]), custom wins, fallback to builtin</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;loadPiece multi-dir&quot; (string|string[], per-user wins, first-dir wins, backward-compat); worker.test <code>loadPiece('local-piece')</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Multi-dir resolution well covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-002</code></td><td class="c-area">Pieces</td><td class="c-feature">validatePieceDef (Phase 6b terminal-next rejection)</td><td class="c-behavior">Throws if any <code>rules[].next</code> ∈ {COMPLETE,ABORT,ASK}; default_next exempt</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;loadPiece terminal-rule validation (Phase 6b)&quot;: rejects COMPLETE/ABORT/ASK in rules[].next, accepts default_next: COMPLETE, accepts movement-to-movement + WAIT_SUBTASKS</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Core invariant directly asserted with error-message matching</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-003</code></td><td class="c-area">Pieces</td><td class="c-feature">default_next sentinel (engine-internal fallback)</td><td class="c-behavior">Used for context-overflow / ASK-limit / SpawnSubTask-skip; allows COMPLETE/ABORT/ASK</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test &quot;falls back to default_next when ASK limit is reached&quot;, &quot;resolves a COMPLETE default_next reached via ASK-limit fallback&quot;, SpawnSubTask-skip→default_next tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">All three sentinel paths exercised</td></tr>
<tr data-cov="NONE" data-area="Pieces"><td class="c-id"><code>PINFRA-004</code></td><td class="c-area">Pieces</td><td class="c-feature">lint-pieces.mjs (CI lint, terminal-next ban)</td><td class="c-behavior">Standalone script: hard-fails any piece with COMPLETE/ABORT/ASK in rules[].next</td><td class="c-source"><code></code></td><td class="c-test">**none** (no *.test for the script; runtime equivalent in validatePieceDef IS tested)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">Script itself has no test; logic duplicated/covered by PINFRA-002 but the CLI (file collection, exit codes, parse-error path) is untested</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-005</code></td><td class="c-area">Pieces</td><td class="c-feature">CreatePiece tool validation</td><td class="c-behavior">Rejects missing/0 max_movements, non-array shared_tools, bad rules[].next; writes to dirs[0] (per-user)</td><td class="c-source"><code></code></td><td class="c-test">tools/pieces.test &quot;CreatePiece&quot; block (missing max_movements, max_movements:0, valid write, shared_tools array/non-array)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Validation + write-target asserted</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-006</code></td><td class="c-area">Pieces</td><td class="c-feature">UpdatePiece tool (built-in protection)</td><td class="c-behavior">Refuses overwriting built-in (isBuiltinOnly); allows update when custom override exists</td><td class="c-source"><code></code></td><td class="c-test">tools/pieces.test &quot;UpdatePiece&quot; block (refuses built-in overwrite regression, allows custom override)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Built-in immutability regression covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-007</code></td><td class="c-area">Pieces</td><td class="c-feature">rules[].next / default_next domain validation (tool path)</td><td class="c-behavior">validRuleNexts = movements WAIT_SUBTASKS; validDefaultNexts adds COMPLETE/ABORT/ASK</td><td class="c-source"><code></code></td><td class="c-test">Covered via CreatePiece tests + pieces-api &quot;rejects rules[].next: COMPLETE&quot; / &quot;accepts default_next: COMPLETE&quot;</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Both accept &amp; reject branches asserted at API + tool level</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-008</code></td><td class="c-area">Pieces</td><td class="c-feature">ListPieces / GetPiece (custom+builtin merge, priority)</td><td class="c-behavior">Lists from per-user + global-custom + builtin; GetPiece prefers dirs[0]</td><td class="c-source"><code></code></td><td class="c-test">tools/pieces.test &quot;customPiecesDir as array — P1-a regression&quot; (ListPieces both dirs, GetPiece second-dir, GetPiece prefers per-user)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Multi-dir merge + priority covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-009</code></td><td class="c-area">Pieces</td><td class="c-feature">piece classifier — buildClassificationPrompt</td><td class="c-behavior">Builds LLM prompt from task + all piece descriptions + keyword hints; biases to chat; routes workspace-app</td><td class="c-source"><code></code></td><td class="c-test">piece-classifier.test &quot;buildClassificationPrompt&quot; (includes text/descriptions, file names, chat bias, workspace-app routing)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Prompt construction + routing bias asserted</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-010</code></td><td class="c-area">Pieces</td><td class="c-feature">piece classifier — parseClassificationResponse</td><td class="c-behavior">Extracts valid piece name from noisy LLM output; longer-match-wins; null on invalid/empty</td><td class="c-source"><code></code></td><td class="c-test">piece-classifier.test &quot;parseClassificationResponse&quot; (valid, noisy, longer-match data-process&gt;general, invalid→null, empty→null)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Parse edge cases covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-011</code></td><td class="c-area">Pieces</td><td class="c-feature">piece catalog (loadAllPieceTriggers / catalog merge)</td><td class="c-behavior">Enumerates all pieces' triggers; custom overrides builtin by name</td><td class="c-source"><code></code></td><td class="c-test">piece-catalog.test (builtin+custom merge, custom override wins, triggers exposed)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Catalog merge &amp; override asserted</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-012</code></td><td class="c-area">Pieces</td><td class="c-feature">pieces-api CRUD + per-user/builtin authz</td><td class="c-behavior">GET/PUT/POST/DELETE with per-user custom dirs, builtin immutability, cross-user authz, ?source=</td><td class="c-source"><code></code></td><td class="c-test">pieces-api.test — extensive: no-auth legacy + auth-aware (built-in 403, own-custom 200, cross-user 403, admin edit-but-not-delete builtin, duplicate-name reject, source resolution)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">One of the most thoroughly tested infra areas</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-013</code></td><td class="c-area">Pieces</td><td class="c-feature">SSH allowlist validation (allowed_ssh_connections)</td><td class="c-behavior">Validates UUID/wildcard/empty allowlist; rejects SshExec without allowlist &amp; bad formats</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test (id-format reject, wildcard, multi-movement offenders) + pieces-api.test (SshExec w/o allowlist, UUID, wildcard, empty, bad format, non-array)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Both engine + API layers covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-014</code></td><td class="c-area">Pieces</td><td class="c-feature">shared_tools / required_mcp normalization</td><td class="c-behavior">Drops invalid entries, warns on SSH shared_tools w/o connections</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test imports normalizeRequiredMcp/normalizeSharedTools/mergeToolNames; tools/pieces + pieces-api shared_tools array/non-array tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Normalization + merge covered</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-015</code></td><td class="c-area">Pieces</td><td class="c-feature">custom/default piece namespace separation (reflection silent fork)</td><td class="c-behavior">Reflection forks built-in into data/users/{u}/pieces with forked_from_commit instead of mutating builtin</td><td class="c-source"><code></code></td><td class="c-test">reflection/silent-fork.test (silentFork chat/data-process into per-user dir)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Silent-fork path covered (reflection subsystem)</td></tr>
<tr data-cov="FULL" data-area="Pieces"><td class="c-id"><code>PINFRA-016</code></td><td class="c-area">Pieces</td><td class="c-feature">all-built-in-pieces load sweep</td><td class="c-behavior">Loops every pieces/*.yaml asserting loadPiece().not.toThrow()</td><td class="c-source"><code></code></td><td class="c-test">piece-runner.test (~line 531-537)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">This is what gives every piece PARTIAL; it validates schema but NOT each piece's movement/rule semantics</td></tr>
<tr data-cov="PARTIAL" data-area="Pieces"><td class="c-id"><code>PINFRA-017</code></td><td class="c-area">Pieces</td><td class="c-feature">per-user piece resolution in worker/scheduler/continue paths</td><td class="c-behavior">customPiecesDir threaded through worker, continue-job, classifier, pieces-api</td><td class="c-source"><code></code></td><td class="c-test">worker.test <code>loadPiece('local-piece', piecesDir, customPieceDirs)</code>; partly via pieces-api auth tests</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Worker path has one test; full propagation across scheduler/continue paths (per the known &quot;no-auth local fallback&quot; checklist) not comprehensively asserted</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-001</code></td><td class="c-area">Reflection</td><td class="c-feature">Enqueue: enabled gate (default OFF)</td><td class="c-behavior"><code>maybeEnqueueReflection</code> returns early when <code>cfg.enabled=false</code></td><td class="c-source"><code>src/worker.ts (363)</code></td><td class="c-test">worker.reflection-enqueue.test.ts (&quot;does nothing when enabled=false&quot;)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">DEFAULT_REFLECTION default value (enabled:false) itself not asserted in a config test</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-002</code></td><td class="c-area">Reflection</td><td class="c-feature">Enqueue: no-recursion on reflection jobs</td><td class="c-behavior">returns early when <code>job.taskKind==='reflection'</code></td><td class="c-source"><code>src/worker.ts (371)</code></td><td class="c-test">worker.reflection-enqueue.test.ts (&quot;does NOT recurse&quot;)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-003</code></td><td class="c-area">Reflection</td><td class="c-feature">Enqueue: no-auth owner fallback to 'local'</td><td class="c-behavior"><code>ownerId ?? 'local'</code> for namespace + budget key</td><td class="c-source"><code>src/worker.ts (377)</code></td><td class="c-test">enqueue.test.ts (2 cases: fallback + budget keyed to 'local')</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-004</code></td><td class="c-area">Reflection</td><td class="c-feature">Enqueue: worker_required gate</td><td class="c-behavior">skip when <code>workerRequired</code> and no worker has role 'reflection'</td><td class="c-source"><code>src/worker.ts (379-388)</code></td><td class="c-test">enqueue.test.ts (3 cases: true+none/true+exists/false)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-005</code></td><td class="c-area">Reflection</td><td class="c-feature">Enqueue: per-user daily token budget cap</td><td class="c-behavior">skip when spent &gt;= cap; cap=0 = unlimited; UTC-day window</td><td class="c-source"><code>src/worker.ts (392-405)</code></td><td class="c-test">enqueue.test.ts (at/under cap, cap=0, UTC 25h, independent users)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-006</code></td><td class="c-area">Reflection</td><td class="c-feature">Enqueue: space_id propagation (Space-as-Principal)</td><td class="c-behavior">carries job.spaceId into payload (null for personal)</td><td class="c-source"><code>src/worker.ts (407-420)</code></td><td class="c-test">enqueue.test.ts (carries space_id / null personal)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-007</code></td><td class="c-area">Reflection</td><td class="c-feature">Dispatch: reflection job routes to runReflectionJob</td><td class="c-behavior">worker picks task_kind='reflection', imports + runs runner, role-keyed routing</td><td class="c-source"><code>src/worker.ts (1129-1130, 2044-2073)</code></td><td class="c-test">worker.reflection-dispatch.test.ts (2 cases)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">dispatch test is small (~2 cases); gateway routing-key + 401-without-key path not asserted end-to-end</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-008</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: loadReflectionInputs (memory/activity/comments/feedback + observedRevisions)</td><td class="c-behavior">assembles inputs, builds bodyRevision CAS map</td><td class="c-source"><code>load-inputs.ts</code></td><td class="c-test">load-inputs.test.ts (~8 cases, CAS)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-009</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: forced submit_reflection tool_call (LLM)</td><td class="c-behavior">callReflectionLlm forces the tool, parses result, tokens</td><td class="c-source"><code>llm-client.ts</code></td><td class="c-test">llm-client.test.ts (~10 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-010</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: prompt construction</td><td class="c-behavior">buildSystemPrompt / buildUserPrompt</td><td class="c-source"><code>reflection-prompt.ts</code></td><td class="c-test">reflection-prompt.test.ts (~18 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-011</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: schema validation of LLM output</td><td class="c-behavior">reflection-schema parse of memory_changes/piece_changes</td><td class="c-source"><code>reflection-schema.ts</code></td><td class="c-test">reflection-schema.test.ts (~10 cases, all ops)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-012</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: end-to-end happy path + outcome + metric row</td><td class="c-behavior">runReflectionJob applies, snapshots, records metric</td><td class="c-source"><code>reflection-runner.ts</code></td><td class="c-test">reflection-runner.test.ts (~12 cases: applied/abstained/failed, snapshot, lock, space, budget)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-013</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: failure fallbacks record outcome='failed'</td><td class="c-behavior">loadInputs/LLM/apply throw → metric 'failed', return 'failed'</td><td class="c-source"><code>reflection-runner.ts</code></td><td class="c-test">reflection-runner.test.ts (failed)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only some of the 3 throw-sites (loadInputs/LLM/apply+snapshot) explicitly covered; writeSnapshot-throws-but-continue branch not clearly asserted</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-014</code></td><td class="c-area">Reflection</td><td class="c-feature">Runner: Space-as-Principal storage resolution</td><td class="c-behavior">space job → data/spaces/{id}; personal → data/users/{userId}; attribution stays per-user</td><td class="c-source"><code>reflection-runner.ts</code></td><td class="c-test">reflection-runner.test.ts (space-principal), revisions.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-015</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_unknown_type</td><td class="c-behavior">type not in {user,feedback,project,reference}</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; applier.fuzz.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-016</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_bad_name</td><td class="c-behavior">name fails isValidMemoryName</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; applier.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-017</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_body_too_large</td><td class="c-behavior">body &gt; maxBodyBytes (UTF-8)</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-018</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_injected_directive</td><td class="c-behavior">prompt-injection heuristics in body/description</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">only validator test hits it; not exercised through applier (low risk, static check)</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-019</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_missing_target</td><td class="c-behavior">non-add op missing merge_target OR target not in index</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; applier.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-020</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_name_collision</td><td class="c-behavior">add with already-existing name</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-021</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_target_piece_mismatch</td><td class="c-behavior">piece_changes.target_piece ≠ input.pieceName</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; applier.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-022</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_invalid_yaml</td><td class="c-behavior">piece new_yaml fails YAML parse</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-023</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_invalid_piece</td><td class="c-behavior">new_yaml parses but fails piece-lint</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-024</code></td><td class="c-area">Reflection</td><td class="c-feature">Validator: rejected_dangerous_piece</td><td class="c-behavior">COMPLETE/ABORT/ASK in rules[].next</td><td class="c-source"><code>semantic-validator.ts</code></td><td class="c-test">semantic-validator.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-025</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply CAS: rejected_stale_target</td><td class="c-behavior">merge_target body revision != snapshot → reject at write time</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts; fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">raised by applier (not static validator); covered</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-026</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply op: add</td><td class="c-behavior">upsertMemoryEntry for new name</td><td class="c-source"><code>applier.ts (applyOne)</code></td><td class="c-test">applier.test.ts; schema; runner</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-027</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply op: update (+CAS)</td><td class="c-behavior">upsert replacing body/desc/type, requires CAS pass</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts (ops + CAS)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-028</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply op: merge_into (+CAS)</td><td class="c-behavior">read existing, append timestamped section, upsert merged</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-029</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply op: remove (+CAS)</td><td class="c-behavior">removeMemoryEntry (trash + index), requires CAS</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-030</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply: missing-target-on-disk at write time</td><td class="c-behavior">non-add op, target gone from disk → rejected_missing_target</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts (rejected_missing_target)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-031</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply: 3-change hard cap</td><td class="c-behavior">memory_changes truncated to 3 with WARN</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts / fuzz</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">cap value 3 truncation likely covered by fuzz inputs but a dedicated &quot;&gt;3 dropped with WARN&quot; assertion not confirmed</td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-032</code></td><td class="c-area">Reflection</td><td class="c-feature">Apply: applyOne write error → code='failed' on decision</td><td class="c-behavior">per-change try/catch records failed</td><td class="c-source"><code>applier.ts</code></td><td class="c-test"></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">the per-change applyOne throw path (decision.code='failed') not obviously asserted (distinct from runner-level 'failed')</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-033</code></td><td class="c-area">Reflection</td><td class="c-feature">Outcome decision: abstained</td><td class="c-behavior">abstain_reason + no applied + no rejected</td><td class="c-source"><code>applier.ts (decideOutcome)</code></td><td class="c-test">applier.test.ts (abstained); runner</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-034</code></td><td class="c-area">Reflection</td><td class="c-feature">Outcome decision: partial</td><td class="c-behavior">any applied AND any rejected/dropped</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts (partial)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-035</code></td><td class="c-area">Reflection</td><td class="c-feature">Outcome decision: applied</td><td class="c-behavior">any applied, none rejected</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts (applied); runner</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-036</code></td><td class="c-area">Reflection</td><td class="c-feature">Outcome decision: rejected</td><td class="c-behavior">any rejected/dropped, none applied</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts (rejected); fuzz</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-037</code></td><td class="c-area">Reflection</td><td class="c-feature">Outcome decision: fallback abstained</td><td class="c-behavior">empty input (0 changes, no piece)</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">the empty-input fallback branch (vs explicit abstain_reason) not clearly distinguished in tests</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-038</code></td><td class="c-area">Reflection</td><td class="c-feature">Piece write: cooldown gate (2 edits/24h)</td><td class="c-behavior">&gt;=2 edits in window → {written:false, reason:'cooldown'} → pieceCooldownDropped</td><td class="c-source"><code>piece-writer.ts; applier.ts</code></td><td class="c-test">piece-writer.test.ts (cooldown); applier.test.ts (cooldown)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-039</code></td><td class="c-area">Reflection</td><td class="c-feature">Piece write: cooldown drop vs semantic reject distinction</td><td class="c-behavior">pieceCooldownDropped separate from pieceRejectCode in outcome</td><td class="c-source"><code>applier.ts</code></td><td class="c-test">applier.test.ts; piece-writer.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-040</code></td><td class="c-area">Reflection</td><td class="c-feature">Piece write: atomic temp+rename + catalog invalidate + recordPieceEdit</td><td class="c-behavior">writePiece tmp.{pid} → rename, repo.recordPieceEdit, catalog.invalidate</td><td class="c-source"><code>piece-writer.ts</code></td><td class="c-test">piece-writer.test.ts (~8 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">atomic-rename crash-safety (partial-write) not stress-tested</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-041</code></td><td class="c-area">Reflection</td><td class="c-feature">Silent fork of built-in piece</td><td class="c-behavior">builtin source → copy to data/users/{userId}/pieces with forked_from_commit/forked_at; idempotent; throws if source missing</td><td class="c-source"><code>silent-fork.ts</code></td><td class="c-test">silent-fork.test.ts (~9 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-042</code></td><td class="c-area">Reflection</td><td class="c-feature">Drift detection (forked_from_commit vs latest builtin commit)</td><td class="c-behavior">detectDrift returns drifted flag; graceful no-op without git</td><td class="c-source"><code>drift-detect.ts</code></td><td class="c-test">drift-detect.test.ts (~7 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-043</code></td><td class="c-area">Reflection</td><td class="c-feature">Snapshot write (.reflection-history/{ts}-{jobId} + index.jsonl append)</td><td class="c-behavior">writeSnapshot persists before/after files, appends index.jsonl atomically under lock</td><td class="c-source"><code>snapshot.ts; reflection-runner.ts</code></td><td class="c-test">snapshot.test.ts (~34 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-044</code></td><td class="c-area">Reflection</td><td class="c-feature">Snapshot: before/after memory file capture</td><td class="c-behavior">runner builds beforeFiles/afterFiles from accepted decisions (remove uses merge_target)</td><td class="c-source"><code>reflection-runner.ts</code></td><td class="c-test">reflection-runner.test.ts; snapshot.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">the remove-op before-file naming branch (merge_target vs name) in runner not specifically asserted</td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-045</code></td><td class="c-area">Reflection</td><td class="c-feature">Snapshot: piece before/after YAML capture</td><td class="c-behavior">reads custom piece path pre/post for snapshot</td><td class="c-source"><code>reflection-runner.ts</code></td><td class="c-test">reflection-runner.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">piece YAML before/after capture lightly covered; existsSync(customPath) edge not asserted</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-046</code></td><td class="c-area">Reflection</td><td class="c-feature">bodyRevision (SHA-1 of parsed body) consistency</td><td class="c-behavior">same helper for loader observedRevisions and applier CAS</td><td class="c-source"><code>revisions.ts</code></td><td class="c-test">revisions.test.ts (~8 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-047</code></td><td class="c-area">Reflection</td><td class="c-feature">Per-user file lock (withUserLock)</td><td class="c-behavior">serializes apply+snapshot critical section (lockfile)</td><td class="c-source"><code>user-lock.ts; applier.ts; reflection-runner.ts</code></td><td class="c-test">user-lock.test.ts; applier.test.ts (lock); runner</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">concurrent contention/torn-write scenario only indirectly assured</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-048</code></td><td class="c-area">Reflection</td><td class="c-feature">Activity log summarizer</td><td class="c-behavior">summarizes activity.log to bounded size for prompt</td><td class="c-source"><code>activity-summarizer.ts</code></td><td class="c-test">activity-summarizer.test.ts (~5 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-049</code></td><td class="c-area">Reflection</td><td class="c-feature">DB: reflection_metrics row (outcome applied/partial/abstained/rejected/failed)</td><td class="c-behavior">recordReflectionMetric inserts one row per reflection job</td><td class="c-source"><code>src/db/repository.ts (~4093-4127)</code></td><td class="c-test">reflection-runner.test.ts; migrate.reflection-columns.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-050</code></td><td class="c-area">Reflection</td><td class="c-feature">DB: reflection_piece_edits cooldown tracking</td><td class="c-behavior">recordPieceEdit insert + count-in-window query</td><td class="c-source"><code>src/db/repository.ts (~4068-4090)</code></td><td class="c-test">piece-writer.test.ts; migrate test</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-051</code></td><td class="c-area">Reflection</td><td class="c-feature">DB: aggregateReflectionMetrics (budget)</td><td class="c-behavior">sums tokens since UTC-day start for budget gate</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">enqueue.test.ts (budget)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-052</code></td><td class="c-area">Reflection</td><td class="c-feature">DB: task_kind / payload columns + reflection job creation</td><td class="c-behavior">migrate adds task_kind, payload; reflection job created with taskKind='reflection'</td><td class="c-source"><code>src/db/repository.ts; migrate.ts</code></td><td class="c-test">migrate.reflection-columns.test.ts (~23 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-053</code></td><td class="c-area">Reflection</td><td class="c-feature">API: GET /history (paged, owner-scoped, nextCursor)</td><td class="c-behavior">lists snapshot index entries, most-recent-first, cursor pagination</td><td class="c-source"><code>bridge/reflection-api.ts</code></td><td class="c-test">reflection-api.test.ts (~29 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-054</code></td><td class="c-area">Reflection</td><td class="c-feature">API: GET /history/:snapshotId (404 no-leak for non-owner/missing)</td><td class="c-behavior">detail or 404 (no existence leak)</td><td class="c-source"><code>bridge/reflection-api.ts</code></td><td class="c-test">reflection-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-055</code></td><td class="c-area">Reflection</td><td class="c-feature">API: POST /history/:snapshotId/revert (idempotent)</td><td class="c-behavior">revertSnapshotForUser; {reverted:true} first, {reverted:false} after</td><td class="c-source"><code>bridge/reflection-api.ts; snapshot.ts</code></td><td class="c-test">reflection-api.test.ts (revert); snapshot.test.ts (revert)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-056</code></td><td class="c-area">Reflection</td><td class="c-feature">API: space-aware store resolution + canEditInSpace authz</td><td class="c-behavior">resolves per-user vs per-space store; space history needs edit rights; 404 on mismatch</td><td class="c-source"><code>bridge/reflection-api.ts</code></td><td class="c-test">reflection-api.test.ts (space-principal)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">canEditInSpace negative path (member-without-edit-rights → 404) not clearly asserted as a distinct multiuser case</td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-057</code></td><td class="c-area">Reflection</td><td class="c-feature">API: GET /metrics</td><td class="c-behavior">aggregated reflection metrics endpoint</td><td class="c-source"><code>bridge/reflection-api.ts</code></td><td class="c-test">reflection-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">endpoint exists; depth of assertion (per-outcome breakdown) unverified</td></tr>
<tr data-cov="PARTIAL" data-area="Reflection"><td class="c-id"><code>REFL-058</code></td><td class="c-area">Reflection</td><td class="c-feature">API: GET /latest-for-task/:taskId (admin)</td><td class="c-behavior">latest snapshot for a task; null (not 404) when none; admin-gated</td><td class="c-source"><code>bridge/reflection-api.ts</code></td><td class="c-test">reflection-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">admin-gating + null-vs-404 distinction not confirmed asserted</td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-059</code></td><td class="c-area">Reflection</td><td class="c-feature">Retention: pruneOldSnapshots (age)</td><td class="c-behavior">delete snapshot dirs older than retentionDays; index.jsonl untouched</td><td class="c-source"><code>retention.ts</code></td><td class="c-test">retention.test.ts (~17 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-060</code></td><td class="c-area">Reflection</td><td class="c-feature">Retention: enforceDiskCap (oldest-first)</td><td class="c-behavior">prune oldest until under capBytes</td><td class="c-source"><code>retention.ts</code></td><td class="c-test">retention.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Reflection"><td class="c-id"><code>REFL-061</code></td><td class="c-area">Reflection</td><td class="c-feature">Retention: runReflectionRetentionSweep (per-user + per-space)</td><td class="c-behavior">sweeps both data/users and data/spaces trees</td><td class="c-source"><code>retention.ts</code></td><td class="c-test">retention.test.ts (space-principal)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-001</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks</td><td class="c-behavior">List tasks filtered by <code>buildVisibilityWhere</code> (owner/admin/org/public/space members)</td><td class="c-source"><code>local-tasks-api.ts:123</code></td><td class="c-test">local-tasks-api.test.ts (visibility list: owner/admin/same-org/diff-org bystander)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-002</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks</td><td class="c-behavior">Create task+job; visibility enum validation, org-scope check, spaceId forces private, owner from req.user (or <code>local</code> no-auth)</td><td class="c-source"><code>local-tasks-api.ts:134</code></td><td class="c-test">local-tasks-api.test.ts (visibility, no-auth owner, runtime_dir, attachments, spaceId), spaces.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-003</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId</td><td class="c-behavior">Get one task; <code>canViewTask</code> visibility gate</td><td class="c-source"><code>local-tasks-api.ts:342</code></td><td class="c-test">space-view.test.ts (membership read gate), spaces.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-004</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/tool-requests</td><td class="c-behavior">List pending tool requests; canViewTask gate</td><td class="c-source"><code>local-tasks-api.ts:361</code></td><td class="c-test">local-tasks-api.test.ts:175 (lists pending)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">view-gate denial path not directly asserted on this route</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-005</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/tool-requests/:reqId/decide</td><td class="c-behavior">Approve/deny a parked tool request; write-gate (403 view-only / 404 non-member), re-queue parked job, 409 already-decided</td><td class="c-source"><code>local-tasks-api.ts:380</code></td><td class="c-test">local-tasks-api.test.ts:143 (approve/deny/409/invalid value/park-contract guard)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-006</code></td><td class="c-area">Bridge core</td><td class="c-feature">PUT /api/local/tasks/:taskId/feedback</td><td class="c-behavior">Submit rating/feedback; owner-or-admin (404 to others even if public)</td><td class="c-source"><code>local-tasks-api.ts:418</code></td><td class="c-test">local-tasks-api.test.ts:740 (owner/admin/non-owner 404), spaces.test.ts:268</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Bridge core"><td class="c-id"><code>APIC-007</code></td><td class="c-area">Bridge core</td><td class="c-feature">PUT /api/local/tasks/:taskId/mission</td><td class="c-behavior">Set/update task mission text</td><td class="c-source"><code>local-tasks-api.ts:442</code></td><td class="c-test">none found (only auth.test.ts matches the word &quot;mission&quot;)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">No test exercises this route — authz + validation unverified</td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-008</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/comments</td><td class="c-behavior">List comments; canViewTask gate</td><td class="c-source"><code>local-tasks-api.ts:474</code></td><td class="c-test">covered indirectly via comment-write-gate.test.ts setup; no dedicated read-gate assertion</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">GET read-gate not directly asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-009</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/comments</td><td class="c-behavior">Post comment (user message → spawns/reuses job); **comment write gate** (owner/admin/editor 201, viewer 403, non-member 404), attachment save, tool_request gate atomicity, idle-job reuse</td><td class="c-source"><code>local-tasks-api.ts:492</code></td><td class="c-test">comment-write-gate.test.ts (owner/admin/editor/viewer-403/non-member-404/personal-404), local-tasks-api.test.ts:816 (ownership, attachments, job reuse, terminal→fresh job)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-010</code></td><td class="c-area">Bridge core</td><td class="c-feature">PATCH /api/local/tasks/:taskId</td><td class="c-behavior">Update task (visibility change, etc.); owner/admin, org-scope validation</td><td class="c-source"><code>local-tasks-api.ts:623</code></td><td class="c-test">local-tasks-api.test.ts:451 (visibility change, invalid enum, org scope), spaces.test.ts:255 (non-member 404)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-011</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/regenerate-title</td><td class="c-behavior">Regenerate AI title; owner/admin only</td><td class="c-source"><code>local-tasks-api.ts:681</code></td><td class="c-test">spaces.test.ts:296 (owner regenerate, +line 318 case)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Only exercised in space context; non-owner-denied / no-generator paths not asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-012</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/evaluate-prompt</td><td class="c-behavior">Prompt-coach: evaluate an instruction via LLM</td><td class="c-source"><code>local-tasks-api.ts:717</code></td><td class="c-test">local-tasks-api.test.ts:1370 (no-generator, blank, happy paths)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-013</code></td><td class="c-area">Bridge core</td><td class="c-feature">DELETE /api/local/tasks/:taskId</td><td class="c-behavior">Delete task; owner-or-admin (404 to others even public)</td><td class="c-source"><code>local-tasks-api.ts:756</code></td><td class="c-test">local-tasks-api.test.ts:336 (owner/admin/non-owner-404), spaces.test.ts:69</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-014</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/cancel</td><td class="c-behavior">Cancel running job; write-gate (editor 200, viewer 403, non-member 404)</td><td class="c-source"><code>local-tasks-api.ts:778</code></td><td class="c-test">comment-write-gate.test.ts:117 (editor/viewer-403/non-member-404), spaces.test.ts:98</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-015</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/continue</td><td class="c-behavior">Spawn child job with new piece; 409 job_in_progress / no_previous_job, handoff comment, instruction persist</td><td class="c-source"><code>local-tasks-api.ts:818</code></td><td class="c-test">local-tasks-api.test.ts:1072 (happy/handoff/persist/409 in-progress/409 no-prev), spaces.test.ts:122 (space_id carry, non-member 404)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Bridge core"><td class="c-id"><code>APIC-016</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/stream</td><td class="c-behavior">SSE stream of job events (tool calls, deltas)</td><td class="c-source"><code>local-tasks-api.ts:914</code></td><td class="c-test">none (<code>/stream</code> matches only in app-share/share/space-api tests)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">SSE event mapping, visibility gate, reconnection all untested</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-017</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/files</td><td class="c-behavior">List files in a section (input/output/logs/workspace); canViewTask gate; traversal 400</td><td class="c-source"><code>local-files-api.ts:41</code></td><td class="c-test">local-files-api.test.ts:74 (default section, subdir, unknown section 400, private-hidden, traversal 400, logs from runtime_dir + workspace_path fallback)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-018</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/files/content</td><td class="c-behavior">Serve file as text/plain; traversal 400, dir 400, missing 404, leading-slash strip</td><td class="c-source"><code>local-files-api.ts:86</code></td><td class="c-test">local-files-api.test.ts:152 (text, requires-path, 404 not 500, dir 400, traversal 400, abs-path strip)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-019</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/files/raw</td><td class="c-behavior">Serve raw bytes by extension; <code>trusted=1</code> drops CSP sandbox (owner-trust model), <code>setUntrustedFileResponseHeaders</code>, <code>ensurePathWithin</code> traversal guard (400)</td><td class="c-source"><code>local-files-api.ts:136</code></td><td class="c-test">local-files-api.test.ts:197 (type-by-ext, sandbox default, trusted for owner/admin/non-owner-shared-viewer/ownerless, no-user-still-sandboxed, no-auth, non-HTML stays sandboxed, traversal 400)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Strong coverage incl. CSP trust matrix</td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-020</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:taskId/files/office-preview</td><td class="c-behavior">Excel→cells JSON / PPTX→slide PNGs; section/path validation 400, soffice missing → 503</td><td class="c-source"><code>local-files-api.ts:203</code></td><td class="c-test">office-preview.test.ts (file-type detect, spreadsheet preview incl. formula, soffice-missing throws) tests the *module*; the HTTP route itself not directly hit</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Module logic FULL; HTTP route (section enum 400, canViewTask gate, 503 mapping) not exercised end-to-end</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-021</code></td><td class="c-area">Bridge core</td><td class="c-feature">PUT /api/local/tasks/:taskId/files/content</td><td class="c-behavior">Write/overwrite output file; owner/admin only, output-section-only, refuse while running, traversal 400, string-content validation</td><td class="c-source"><code>local-files-api.ts:240</code></td><td class="c-test">local-files-api.test.ts:290 (owner write, non-owner 404, admin, refuse-running, output-only, requires-string, traversal 400)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-022</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/files/upload</td><td class="c-behavior">Upload files to input/ (≤60mb); owner/admin, O_EXCL rename-on-collision, name sanitize, refuse-running, section/empty/traversal 400</td><td class="c-source"><code>local-files-api.ts:324</code></td><td class="c-test">local-files-api.test.ts:366 (input upload, subdir, collision rename, name sanitize, non-owner 404, admin, refuse-running, logs/workspace 400, empty 400, traversal 400)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-023</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/files/delete</td><td class="c-behavior">Delete files; owner/admin, idempotent skip-missing, skip-dirs, refuse-running, validate-all-first (no partial), traversal 400</td><td class="c-source"><code>local-files-api.ts:392</code></td><td class="c-test">local-files-api.test.ts:470 (delete, single path, skip-missing/dirs, non-owner 404, refuse-running, logs 400, empty 400, traversal 400, validate-all-first)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-024</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/local/tasks/:taskId/files/download-zip</td><td class="c-behavior">Zip selected files; read-gated (non-owner of public OK), all sections, <code>safeZipEntryName</code> zip-slip protection, traversal 400, empty 400</td><td class="c-source"><code>local-files-api.ts:447</code></td><td class="c-test">local-files-api.test.ts:547 (owner zip, logs section, non-owner-public read, private 404, 404-nothing-matches, traversal 400, empty 400, zip-slip backslash, ..-strip)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-025</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/pieces</td><td class="c-behavior">List pieces (builtin + global-custom + caller's user-custom, priority-merged, no hiding)</td><td class="c-source"><code>pieces-api.ts:251</code></td><td class="c-test">pieces-api.test.ts (no-auth list, auth-aware merge, same-name no-hide, source tagging)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-026</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/pieces/:name</td><td class="c-behavior">Get one piece; <code>?source=</code> selector, invalid source → 400, priority resolution</td><td class="c-source"><code>pieces-api.ts:325</code></td><td class="c-test">pieces-api.test.ts (404 unknown, ?source=builtin, priority resolution, invalid-source typo 400)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-027</code></td><td class="c-area">Bridge core</td><td class="c-feature">PUT /api/pieces/:name</td><td class="c-behavior">Update; builtin by non-admin 403, admin can edit builtin, own user-custom 200, others' 403, lint (rules[].next COMPLETE reject, SshExec allowlist, shared_tools)</td><td class="c-source"><code>pieces-api.ts:370</code></td><td class="c-test">pieces-api.test.ts (builtin-non-admin 403, own 200, other-user 403, admin builtin, ?source typo 400, lint rules)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-028</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/pieces</td><td class="c-behavior">Create; dup-name reject, builtin-name reject, no-auth→piecesDir vs user-custom, 503 when userPiecesRootDir unset for authed non-admin/admin, returns actual source</td><td class="c-source"><code>pieces-api.ts:430</code></td><td class="c-test">pieces-api.test.ts (create, dup reject, builtin-name reject, 503 fallback hole, source return, no-auth local user-custom)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-029</code></td><td class="c-area">Bridge core</td><td class="c-feature">DELETE /api/pieces/:name</td><td class="c-behavior">Delete; builtin non-deletable 403 (even admin), own user-custom 200, ?source typo 400 no-destructive-fallback</td><td class="c-source"><code>pieces-api.ts:490</code></td><td class="c-test">pieces-api.test.ts (builtin 403, own 200, admin-cannot-delete-builtin, ?source=user-custom target, typo 400)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-030</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/config</td><td class="c-behavior">Return v2 config + ETag; omits legacy provider/flat-storage keys; admin-only mount</td><td class="c-source"><code>config-api.ts:87</code></td><td class="c-test">config-api.test.ts (v2 shape+etag, omits legacy, exposes storage.*)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Note: sensitive-masking observed in tests as worker api_key exclusion (APIC-033), not on this raw GET</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-031</code></td><td class="c-area">Bridge core</td><td class="c-feature">PUT /api/config</td><td class="c-behavior">Update with If-Match ETag optimistic lock → **409 on stale etag**; legacy-key reject 400; force config_version=2; YAML round-trip</td><td class="c-source"><code>config-api.ts:93</code></td><td class="c-test">config-api.test.ts (round-trip, force-version, legacy-key 400s ×6, storage round-trip, **409 stale etag**)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-032</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/config/reload</td><td class="c-behavior">Reload config from file (500 on parse error)</td><td class="c-source"><code>config-api.ts:117</code></td><td class="c-test">config-api.test.ts:208 (reload from file)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">500 error path not asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-033</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/workers</td><td class="c-behavior">List workers, allowlisted fields only (**no api_key leak**), synthesized default, proxy/proxyType exposed; 401 unauth</td><td class="c-source"><code>config-api.ts:126</code></td><td class="c-test">config-api.test.ts (default synth, allowlist fields, proxy no-key-leak, 401 unauth, 200 authed)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">This is the de-facto sensitive-masking test</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-034</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/workers/:workerId/backends</td><td class="c-behavior">Fetch /v1/models from proxy worker; 404 unknown, direct→empty, 60s cache, scheme/URL validation (no apiKey leak to fetch), 502 upstream fail, 401 unauth</td><td class="c-source"><code>config-api.ts:149</code></td><td class="c-test">config-api.test.ts (404, direct empty, fetch list, cache, file://+data: reject, malformed reject, 502, 401)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-035</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/tools</td><td class="c-behavior">Runtime tool catalog (builtin+meta+MCP+ssh w/ availability flags); <code>?legacy=1</code> flat array; 401 unauth</td><td class="c-source"><code>tools-api.ts:242</code></td><td class="c-test">tools-api.test.ts (catalog, source/category tags, meta scope, ssh avail/unavail, MCP authed/offline/no-user, 401, legacy flat, placeholder, TestWorkspaceApp)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-036</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/scheduled-tasks</td><td class="c-behavior">List; visibility filter, <code>?spaceId=</code> scope</td><td class="c-source"><code>scheduled-tasks-api.ts:105</code></td><td class="c-test">scheduled-tasks-api.test.ts (list, visibility filter owner/non-owner/admin, ?spaceId scope)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-037</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/scheduled-tasks/:id</td><td class="c-behavior">Get one scheduled task</td><td class="c-source"><code>scheduled-tasks-api.ts:120</code></td><td class="c-test">scheduled-tasks-api.test.ts (covered via list/manage setup)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">No dedicated GET-by-id view-gate assertion</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-038</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/scheduled-tasks</td><td class="c-behavior">Create; visibility enum+org-scope validation, spaceId forces private + edit-rights fallback to NULL, no-auth synthetic <code>local</code> owner, requires body</td><td class="c-source"><code>scheduled-tasks-api.ts:133</code></td><td class="c-test">scheduled-tasks-api.test.ts (visibility, org reject, spaceId force-private/edit-fallback/see-not-edit-fallback, no-auth local, browserSessionProfile owner check, daily, require-body)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-039</code></td><td class="c-area">Bridge core</td><td class="c-feature">PATCH /api/scheduled-tasks/:id</td><td class="c-behavior">Update; <code>canManageSchedule</code> (owner/admin/space-editor); **non-owner may manage LIFECYCLE only (pause/resume/reschedule/visibility) — content edits owner-only (P3 confused-deputy guard)**; keeps space schedule private</td><td class="c-source"><code>scheduled-tasks-api.ts:267</code></td><td class="c-test">scheduled-tasks-api.test.ts (pause/resume, owner-or-admin 404 paths, admin any, space-editor manage, lost-edit-rights, keep-private)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Confused-deputy split (lifecycle vs content) is the known P3 design caveat</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-040</code></td><td class="c-area">Bridge core</td><td class="c-feature">DELETE /api/scheduled-tasks/:id</td><td class="c-behavior">Delete; owner/admin/space-editor (non-owner 404)</td><td class="c-source"><code>scheduled-tasks-api.ts:461</code></td><td class="c-test">scheduled-tasks-api.test.ts (owner, admin any, non-owner 404, space-editor survives-creator-leaving)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-041</code></td><td class="c-area">Bridge core</td><td class="c-feature">POST /api/scheduled-tasks/:id/trigger</td><td class="c-behavior">Manually trigger a run; canManageSchedule gate</td><td class="c-source"><code>scheduled-tasks-api.ts:480</code></td><td class="c-test">scheduled-tasks-api.test.ts:203 (creator who lost edit rights can no longer trigger)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">Happy-path trigger spawning a run not directly asserted; only the denial path</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-042</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:id/subtasks/activities</td><td class="c-behavior">Aggregate subtask activity tree; canViewTask gate (404 hidden, public allowed)</td><td class="c-source"><code>subtask-activity-api.ts:28</code></td><td class="c-test">subtask-activity-api.test.ts (nested when waiting_subtasks, 404 not-found, 404 cannot-see, public allowed)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-043</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:id/subtasks/:jobId/activity</td><td class="c-behavior">Single subtask activity; 404 subtask-not-found</td><td class="c-source"><code>subtask-activity-api.ts:68</code></td><td class="c-test">subtask-activity-api.test.ts:211 (404 subtask not found)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">view-gate denial on this specific route not separately asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-044</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:id/subtasks/:jobId/files</td><td class="c-behavior">List subtask worktree files; canViewTask, <code>isJobWithinWorkspace</code> containment 404, 404 no-worktree</td><td class="c-source"><code>subtask-files-api.ts:12</code></td><td class="c-test">subtask-files-api.test.ts:67 (grouped listing, 400 bad id, 404 missing/private/no-worktree/outside-workspace)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge core"><td class="c-id"><code>APIC-045</code></td><td class="c-area">Bridge core</td><td class="c-feature">GET /api/local/tasks/:id/subtasks/:jobId/files/*</td><td class="c-behavior">Serve subtask file; <code>resolve</code>+<code>startsWith(base+sep)</code> traversal → 403, 404 missing, canViewTask, org-member allowed, sendFile</td><td class="c-source"><code>subtask-files-api.ts:55</code></td><td class="c-test">subtask-files-api.test.ts:121 (serve inside worktree, 404 missing, 404 cannot-see, org-member allowed)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">The 403 traversal branch (resolved≠base path-escape) is not directly asserted on this route</td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-046</code></td><td class="c-area">Bridge core</td><td class="c-feature"><code>isJobWithinWorkspace</code></td><td class="c-behavior">Separator-bounded containment (rejects /12 vs /123 prefix sibling)</td><td class="c-source"><code>local-api-helpers.ts</code></td><td class="c-test">local-api-helpers.test.ts:8 (descendants, sibling-prefix reject, null/empty)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-047</code></td><td class="c-area">Bridge core</td><td class="c-feature"><code>ensurePathWithin</code> / <code>isPathEscapeError</code></td><td class="c-behavior">Path-traversal + **symlink-escape** hardening, sibling-prefix bypass guard</td><td class="c-source"><code>local-api-helpers.ts</code></td><td class="c-test">local-api-helpers.test.ts:92,106 (inside ok, traversal reject, sibling-prefix, symlink-escape, in-root resolve)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-048</code></td><td class="c-area">Bridge core</td><td class="c-feature"><code>safeZipEntryName</code></td><td class="c-behavior">Neutralizes .., backslash, Windows drive-letter in zip entry names (zip-slip)</td><td class="c-source"><code>local-api-helpers.ts</code></td><td class="c-test">local-api-helpers.test.ts:25 (plain path, never-absolute, drive-letter→C_)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-049</code></td><td class="c-area">Bridge core</td><td class="c-feature"><code>setUntrustedFileResponseHeaders</code></td><td class="c-behavior">nosniff + CSP sandbox on untrusted file responses</td><td class="c-source"><code>local-api-helpers.ts</code></td><td class="c-test">local-api-helpers.test.ts:78</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge core"><td class="c-id"><code>APIC-050</code></td><td class="c-area">Bridge core</td><td class="c-feature">office-preview module (<code>getSpreadsheetPreview</code>/<code>getPresentationPreview</code>/<code>SofficeUnavailableError</code>)</td><td class="c-behavior">Excel cells incl. formula results, PPTX via soffice, throws when soffice absent (→503)</td><td class="c-source"><code>office-preview.ts</code></td><td class="c-test">office-preview.test.ts (file detect, spreadsheet+formula, soffice-missing throws)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">Module-level only; see APIC-020 for the HTTP wiring gap</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-001</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">OAuth2 Google login (<code>GET /auth/google</code>, callback)</td><td class="c-behavior">Passport Google strategy, status gate (active→app, pending→/auth/pending)</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-002</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">OAuth2 Gitea login (<code>GET /auth/gitea</code>, callback)</td><td class="c-behavior">Passport OAuth2 Gitea; pending→admin auto-promote if adminEmails</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">callback strategy logic tested indirectly; live OAuth handshake not E2E</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-003</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Local auth login (<code>POST /auth/local</code>)</td><td class="c-behavior">username/password login w/ rate limiter</td><td class="c-source"><code>auth.ts, login-rate-limit.ts</code></td><td class="c-test">auth.local.test.ts, login-rate-limit.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-004</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Local signup (<code>POST /auth/local/signup</code>)</td><td class="c-behavior">self-register → pending status awaiting approval</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.local.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-005</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Login rate limiting</td><td class="c-behavior"><code>LoginRateLimiter</code> + per-IP throttle scope</td><td class="c-source"><code>login-rate-limit.ts, auth.ts</code></td><td class="c-test">login-rate-limit.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-006</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature"><code>requireAuth</code> middleware</td><td class="c-behavior">authenticated AND status==='active' else 401/redirect</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-007</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature"><code>requireAdmin</code> middleware</td><td class="c-behavior">role==='admin' else 401/403</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">exercised via admin routers; few direct unit cases</td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-008</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature"><code>GET /auth/status</code> / <code>/login</code> / <code>/pending</code> / <code>/logout</code></td><td class="c-behavior">session state pages + logout</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">logout/pending HTML paths lightly covered</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-009</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Session store (persistent secret, survive restart)</td><td class="c-behavior">auto-gen session secret, short-secret warning</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.session-store.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-010</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Gitea orgs fetch (<code>/api/v1/user/orgs</code>)</td><td class="c-behavior">populates session.orgIds for 'org' visibility</td><td class="c-source"><code>auth.ts (`resolveOrgIds`)</code></td><td class="c-test">auth.test.ts, local-orgs.integration.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">fetch failure/network-error path not asserted</td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-011</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Change password (<code>requireAuth</code> + current-password)</td><td class="c-behavior">requires current pw, 204 on success</td><td class="c-source"><code>auth.ts</code></td><td class="c-test">auth.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">wrong-current-pw rejection coverage thin</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-012</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Admin users CRUD (<code>GET/POST/PATCH/DELETE /api/admin/users</code>)</td><td class="c-behavior">**admin-only** (guard); create/approve/disable/set-password</td><td class="c-source"><code>admin-api.ts</code></td><td class="c-test">admin-api.test.ts, admin-api.local.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-013</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Admin orgs CRUD + members (<code>/api/admin/orgs*</code>)</td><td class="c-behavior">**admin-only**; org create/edit/delete, add/remove member</td><td class="c-source"><code>admin-api.ts</code></td><td class="c-test">admin-api.test.ts, local-orgs.integration.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-014</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">**No-auth admin passthrough**</td><td class="c-behavior">when authActive=false, admin guard → passthrough</td><td class="c-source"><code>admin-api.ts, branding-api.ts</code></td><td class="c-test">admin-api.local.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">branding adminGuard no-auth path not asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-015</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Task share create (<code>POST /api/local/tasks/:id/share</code>)</td><td class="c-behavior">**owner-or-admin only** (<code>checkTaskOwnership</code>) → token</td><td class="c-source"><code>share-api.ts</code></td><td class="c-test">share-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-016</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Task unshare (<code>DELETE .../share</code>)</td><td class="c-behavior">**owner-or-admin only**</td><td class="c-source"><code>share-api.ts</code></td><td class="c-test">share-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-017</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Public shared task read (<code>GET /api/shared/:token</code>)</td><td class="c-behavior">token-only (no auth); invalid/revoked token → 404</td><td class="c-source"><code>share-api.ts</code></td><td class="c-test">share-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-018</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Shared task comments/files/raw (<code>/api/shared/:token/...</code>)</td><td class="c-behavior">token-scoped read of files/comments/raw content</td><td class="c-source"><code>share-api.ts</code></td><td class="c-test">share-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">path-escape on <code>/files/content</code> lightly tested</td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-019</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Shared subtask activities/files (<code>/api/shared/:token/subtasks/*</code>)</td><td class="c-behavior">token-scoped subtask containment (<code>isJobWithinWorkspace</code> prefix guard)</td><td class="c-source"><code>share-api.ts</code></td><td class="c-test">share-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">wildcard <code>/subtasks/:jobId/files/*</code> traversal not directly asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-020</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Public app-share resolve (<code>GET /api/app-share/:token</code>)</td><td class="c-behavior">token→(spaceId,appName); revoked/invalid→404</td><td class="c-source"><code>app-share-api.ts</code></td><td class="c-test">app-share-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-021</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">App-share file read (content/raw/list)</td><td class="c-behavior">**server-side path containment**: outside allowed route→403; escape→403</td><td class="c-source"><code>app-share-api.ts</code></td><td class="c-test">app-share-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">strong path-escape/forbidden coverage</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-022</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">App-share is GET-only</td><td class="c-behavior">no write/delete endpoints (read-only public share)</td><td class="c-source"><code>app-share-api.ts</code></td><td class="c-test">app-share-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-023</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space list (<code>GET /spaces/</code>)</td><td class="c-behavior">visibility-scoped list via repo viewer gate</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-024</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space get (<code>GET /spaces/:id</code>)</td><td class="c-behavior"><code>getSpace({viewer})</code> null→404 (private/org/public gate)</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-025</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space create (<code>POST /spaces/</code>)</td><td class="c-behavior">authenticated user creates space (owner)</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-026</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space update/archive (<code>PATCH /:id</code>, <code>POST /:id/archive</code>)</td><td class="c-behavior">**canManageSpace** (owner/admin/member-owner) else 403</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.test.ts, space-api.members.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-027</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space file list/content/raw/office-preview (<code>GET /:id/files*</code>)</td><td class="c-behavior">read gated by <code>getSpace({viewer})</code> visibility; not-found→404</td><td class="c-source"><code>space-api.ts, office-preview.ts</code></td><td class="c-test">space-api.test.ts, office-preview.test.ts, local-files-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">non-member-on-public read path not asserted per-endpoint</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-028</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space file upload/delete (<code>POST /:id/files/upload</code>,<code>/delete</code>)</td><td class="c-behavior">**canEditInSpace** (owner/admin/editor); viewer→403</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.write-gates.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">strong write-gate authz coverage (20 authz assertions)</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-029</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space file mkdir/move/download-zip (<code>POST /:id/files/...</code>)</td><td class="c-behavior">**canEditInSpace**; protected top-dirs blocked</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.write-gates.test.ts, space-api.write-endpoint.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-030</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space file write (<code>POST /:id/files/write</code>)</td><td class="c-behavior">**canEditInSpace** write gate</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.write-endpoint.test.ts, space-api.write-gates.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-031</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space calendar read (<code>GET /:id/calendar</code>, <code>/calendar/day</code>)</td><td class="c-behavior">visibility-gated read</td><td class="c-source"><code>space-api.ts, cross-calendar-api.ts</code></td><td class="c-test">space-api.calendar.test.ts, cross-calendar-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-032</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space calendar event CRUD (<code>POST/PATCH/DELETE /:id/calendar/events*</code>)</td><td class="c-behavior">edit gate; multi-day mirror</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.calendar.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">non-editor write rejection coverage lighter than file gates</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-033</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space members list (<code>GET /:id/members</code>)</td><td class="c-behavior">members visible; **email masked** unless admin/owner/self-member</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.members.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">PII masking explicitly tested</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-034</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space member add/role/remove (<code>POST/PATCH/DELETE /:id/members*</code>)</td><td class="c-behavior">**canManageSpace**; owner-add blocked(400); self-removal allowed; non-member→404</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.members.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">27 authz assertions</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-035</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space invite issue/get/revoke (<code>GET/POST/DELETE /:id/invite</code>)</td><td class="c-behavior">**canManageSpace**; no-auth→404</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.invite.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-036</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Invite preview + accept (<code>GET /invite/:token</code>, <code>POST /invite/:token/accept</code>)</td><td class="c-behavior">authed user joins via valid token; invalid/expired→404; no-auth→404</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.invite.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">maxUses/expiry validity via <code>isSpaceInviteValid</code></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-037</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">App share per-space (<code>GET/POST/DELETE /:id/apps/:app/share</code>)</td><td class="c-behavior">manage-gated issue/revoke of public app link</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.app-share.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">6 authz assertions; non-manager revoke path thin</td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-038</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Space tool-policy (<code>GET /:id/tool-policy</code>, <code>PUT</code>)</td><td class="c-behavior">GET visibility-gated; **PUT canManageSpace**; sensitive (Bash/SSH/browser/MCP) opt-in</td><td class="c-source"><code>space-api.ts</code></td><td class="c-test">space-api.tool-policy.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only 9 authz assertions; Bash/sensitive write-path round-trip (PR #653 regression area) under-covered</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-039</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Browser captcha-pool (<code>GET/DELETE /captcha-pool</code>)</td><td class="c-behavior">captcha session pool read/clear</td><td class="c-source"><code>browser-api.ts</code></td><td class="c-test">browser-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-040</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Browser task-session (<code>GET/POST /task-session/:taskId*</code>)</td><td class="c-behavior">per-task browser session acquire/release</td><td class="c-source"><code>browser-api.ts</code></td><td class="c-test">browser-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-041</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Browser profile list (<code>GET /</code> )</td><td class="c-behavior">space-aware list; **decryptableByViewer** flag (DEK owner-bound)</td><td class="c-source"><code>browser-session-api.ts</code></td><td class="c-test">browser-session-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">non-owner sees but cannot decrypt — asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-042</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Browser profile create (<code>POST</code>)</td><td class="c-behavior">space ctx → **canEditInSpace**; personal → owner-scoped; spaceId ignored when no spaceAccess</td><td class="c-source"><code>browser-session-api.ts</code></td><td class="c-test">browser-session-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-043</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Browser profile delete (<code>DELETE /:id</code>)</td><td class="c-behavior">space editor/owner OR personal owner-only</td><td class="c-source"><code>browser-session-api.ts</code></td><td class="c-test">browser-session-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-044</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Browser interactive login session (<code>POST /profiles/:id/login</code>)</td><td class="c-behavior">spawn noVNC session for owner; DEK owner-bound</td><td class="c-source"><code>browser-session-api.ts, novnc-proxy.ts</code></td><td class="c-test">browser-session-api.test.ts, novnc-proxy.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">noVNC proxy authz/upgrade path lightly covered</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-045</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Console WS attach (<code>attachConsoleWs</code> upgrade)</td><td class="c-behavior"><code>decideAccess</code>: visibility gate + canWrite (owner OR admin)</td><td class="c-source"><code>console-ws-api.ts</code></td><td class="c-test">console-ws-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">decideAccess unit-tested</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-046</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Console status router (<code>createConsoleStatusRouter</code>)</td><td class="c-behavior">requireAuth; status poll for owner/admin</td><td class="c-source"><code>console-ws-api.ts</code></td><td class="c-test">console-ws-api.test.ts, console-session-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-047</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Console session router (<code>createConsoleSessionRouter</code>)</td><td class="c-behavior">requireAuth task-scoped session resolution</td><td class="c-source"><code>console-ws-api.ts</code></td><td class="c-test">console-session-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-048</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Console admin (<code>GET /ssh/console-sessions</code>, <code>POST .../:taskId/kill</code>)</td><td class="c-behavior">**requireAdmin** list/kill console sessions</td><td class="c-source"><code>console-admin-api.ts</code></td><td class="c-test">console-admin-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-049</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Gateway admin CRUD (<code>POST/PATCH/DELETE /:id</code>, rotate/revoke keys)</td><td class="c-behavior">**requireAdmin** backend + API-key mgmt</td><td class="c-source"><code>admin-gateway-api.ts</code></td><td class="c-test">admin-gateway-api.test.ts, .budget-rate.test.ts, .metric-labels.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">18 authz assertions; budget/rate/labels covered</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-050</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Gateway usage (<code>GET /:id/usage</code>)</td><td class="c-behavior">**requireAdmin** per-backend usage</td><td class="c-source"><code>admin-gateway-api.ts</code></td><td class="c-test">admin-gateway-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-051</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Gateway status (<code>GET /</code> status)</td><td class="c-behavior">non-admin status snapshot</td><td class="c-source"><code>admin-gateway-status-api.ts</code></td><td class="c-test">admin-gateway-status-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-052</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Gateway mount/proxy (<code>mountGateway</code>, classifyGatewayPath)</td><td class="c-behavior">LLM gateway request routing/classification middleware</td><td class="c-source"><code>gateway-mount.ts</code></td><td class="c-test">gateway-mount.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">path classification + config-equiv heavily tested; auth on proxied LLM calls (Bearer/key) not asserted in this file</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-053</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Notifications VAPID key (<code>GET /api/notifications/vapid-public-key</code>)</td><td class="c-behavior">requireAuth</td><td class="c-source"><code>notifications-api.ts</code></td><td class="c-test">notifications-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-054</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Notifications subscriptions (<code>GET/POST/DELETE /subscriptions</code>)</td><td class="c-behavior">requireAuth; web-push subscribe/unsubscribe</td><td class="c-source"><code>notifications-api.ts</code></td><td class="c-test">notifications-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-055</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Notifications preferences (<code>GET/PUT /preferences</code>)</td><td class="c-behavior">requireAuth pref read/write</td><td class="c-source"><code>notifications-api.ts</code></td><td class="c-test">notifications-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-056</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Notifications test/send (<code>POST</code> send endpoints)</td><td class="c-behavior">requireAuth push trigger</td><td class="c-source"><code>notifications-api.ts</code></td><td class="c-test">notifications-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">send/test push delivery path lightly asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-057</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Branding read (<code>GET /api/branding</code>)</td><td class="c-behavior">public branding config read</td><td class="c-source"><code>branding-api.ts</code></td><td class="c-test">branding-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-058</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Branding static serve (<code>/branding/*</code>)</td><td class="c-behavior">static asset serving</td><td class="c-source"><code>branding-api.ts</code></td><td class="c-test">branding-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">static traversal not asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-059</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Branding upload/delete (<code>POST/DELETE /api/branding/upload</code>)</td><td class="c-behavior">**adminGuard** (passthrough in no-auth)</td><td class="c-source"><code>branding-api.ts</code></td><td class="c-test">branding-api.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-060</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Security headers / HSTS opt-in</td><td class="c-behavior">HSTS default off (<code>max-age=0</code> clear), opt-in enable; 180d max-age</td><td class="c-source"><code>security-headers.ts</code></td><td class="c-test">security-headers.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">15 expects (no authz; correct)</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-061</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Server TLS listener</td><td class="c-behavior">HTTPS listener creation; cert handling</td><td class="c-source"><code>server-tls-listener.ts</code></td><td class="c-test">server-tls-listener.test.ts, server.tls.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-062</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Dashboard API (<code>createDashboardApi</code>)</td><td class="c-behavior">worker/node (GPU) status tabs</td><td class="c-source"><code>dashboard-api.ts</code></td><td class="c-test">dashboard-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">no explicit auth guard visible in router; visibility of dashboard data not gated/asserted</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-063</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Dashboard workers</td><td class="c-behavior">worker status aggregation</td><td class="c-source"><code>dashboard-workers.ts</code></td><td class="c-test">dashboard-workers.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-064</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature"><code>buildVisibilityWhere</code> / <code>buildSpaceVisibilityWhere</code></td><td class="c-behavior">SQL WHERE for private/org/public</td><td class="c-source"><code>visibility.ts</code></td><td class="c-test">visibility.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">53 expects</td></tr>
<tr data-cov="FULL" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-065</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature"><code>canManageSpace</code> / <code>canEditInSpace</code> / <code>canEditEntity</code> / <code>canUserSeeTask</code></td><td class="c-behavior">role/owner/admin authz predicates</td><td class="c-source"><code>visibility.ts</code></td><td class="c-test">visibility.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">central predicates well unit-tested</td></tr>
<tr data-cov="NONE" data-area="Bridge auth &amp; spaces"><td class="c-id"><code>APIS-066</code></td><td class="c-area">Bridge auth &amp; spaces</td><td class="c-feature">Cross-space credential transfer (G3/#006)</td><td class="c-behavior">copy MCP/SSH creds between spaces, per-user token non-copy</td><td class="c-source"><code>(PR #642 — NOT in main)</code></td><td class="c-test">(n/a in main)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">**Not merged to main** — no endpoint present; out of scope for current tree</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-001</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Worker poll/claim loop</td><td class="c-behavior"><code>processNext</code>/<code>acquireJobOrRequeue</code> claim a queued job or requeue when issue lock held</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.test.ts (&quot;requeues a claimed job when the issue lock is already held&quot;)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">poll cadence, <code>pokePoll</code>, empty-queue path not directly asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-002</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Role / task_class matching</td><td class="c-behavior"><code>canClaimRole</code>/<code>supportsRole</code>/<code>getSupportedRoles</code> gate which jobs a worker claims</td><td class="c-source"><code>src/worker.ts, src/db/repository.ts (claimNextJob)</code></td><td class="c-test">repository.test.ts (&quot;claims only jobs matching a healthy worker role set&quot;; &quot;does not let unhealthy workers claim queued jobs&quot;)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">role-set matching + healthy filter covered at repo layer</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-003</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Title-only / reflection role filters</td><td class="c-behavior"><code>roles:['title']</code> skips agent jobs; <code>roles:['reflection']</code> runs reflection handler</td><td class="c-source"><code>src/worker.ts (handleReflectionJob, supportsRole)</code></td><td class="c-test">worker.reflection-dispatch.test.ts; worker.reflection-enqueue.test.ts (16)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">reflection enqueue + dispatch well covered; title-only worker skip path has no direct test</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-004</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Job lifecycle transitions</td><td class="c-behavior">queued→dispatching→running→succeeded/failed/waiting_human/waiting_subtasks via updateJob</td><td class="c-source"><code>src/worker.ts, src/db/repository.ts</code></td><td class="c-test">worker.test.ts; repository.test.ts; spaces-task-ops (cancel flips running→cancelled)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">terminal mapping covered (metrics test); waiting_human/waiting_subtasks transitions only indirectly exercised</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-005</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Retry / maxAttempts policy</td><td class="c-behavior">terminal vs transient classification; one recovery retry for max_iterations; give up on repeat</td><td class="c-source"><code>src/worker.ts (executeJob retry logic)</code></td><td class="c-test">worker.test.ts (5 retry cases incl. self-abort, max_iterations 1st/2nd hit, llm_error budget)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">strong branch coverage of terminal vs transient + attempt budget</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-006</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Retry handoff summary</td><td class="c-behavior"><code>buildRetryHandoffSummary</code>/<code>writeRetryHandoffSummary</code> carry lessons+diagnostics into retry</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.test.ts (&quot;writes retry handoff summary&quot;; &quot;builds retry handoff summary from diagnostics and lessons&quot;)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-007</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Deadline / hard-abort guard</td><td class="c-behavior"><code>shouldDeadlineAbort</code> aborts past deadline; job-guard timer force-aborts</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.job-guard.test.ts (8: before/after deadline, disabled=0, no re-abort, cancel mid-run, hard deadline)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-008</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Cancellation check</td><td class="c-behavior"><code>cancelCheck</code> returns true when DB job status is cancelled</td><td class="c-source"><code>src/worker.ts, repository.requestJobCancel</code></td><td class="c-test">worker.test.ts (&quot;cancelCheck returns true when cancelled&quot;); repository.test.ts (requestJobCancel 3 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">running→cancelled, queued no-op, unknown id all covered</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-009</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">max_concurrency slot scheduling</td><td class="c-behavior">runs up to N concurrent, refills freed slot, single-flight when unset</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.concurrency.test.ts (5: concurrent, single-flight, waitForCompletion, decrement-on-throw)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-010</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Initialize / health probe</td><td class="c-behavior"><code>initialize</code> probes /api/tags + /v1/models, forwards Bearer, stays healthy on llama-server compat</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.test.ts (3 initialize cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-011</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Unhealthy on LLM conn error</td><td class="c-behavior">requeues jobs + marks worker unhealthy on connection errors</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.test.ts (&quot;requeues jobs and marks the worker unhealthy on LLM connection errors&quot;)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-012</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">resolveToolSpaceId (per-space MCP/SSH)</td><td class="c-behavior">NULL space + owner → personal space; keeps explicit; fails CLOSED on resolver error</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.test.ts (5 resolveToolSpaceId cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">fail-closed sentinel asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-013</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Piece resolution for null-owner job</td><td class="c-behavior">no-auth job loads piece from data/users/local/pieces consistently</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.test.ts (2 null-ownerId piece cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-014</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">prepareJobWorkspace resolution</td><td class="c-behavior">uses stored workspacePath or falls back to legacy local/{taskId} + backfills</td><td class="c-source"><code>src/worker.ts, src/spaces/workspace-resolver.ts</code></td><td class="c-test">worker.test.ts (2 cases); workspace-resolver.test.ts (6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">persistent/ephemeral/personal + backfill covered</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-015</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">maybeEnqueueReflection budget gate</td><td class="c-behavior">enqueue/skip by enabled flag, daily UTC budget cap, worker-required, per-user keying</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.reflection-enqueue.test.ts (16)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">strong: cap=0, 25h window, no-recursion, independent budgets</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-016</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">resolveModel / role→model mapping</td><td class="c-behavior">selects backend model for the job role</td><td class="c-source"><code>src/worker.ts (resolveModel)</code></td><td class="c-test">worker/sticky-backend.test.ts (resolver follow-current); scheduling.test.ts (normalizeJobRole/parseUiRole)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">role parsing well covered; resolveModel's own fallback branches not directly tested</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-017</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Sticky backend resolver</td><td class="c-behavior">persists first backend, advances on CHANGE, retries on persist failure</td><td class="c-source"><code>src/worker.ts (createStickyBackendResolver)</code></td><td class="c-test">worker/sticky-backend.test.ts (6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-018</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Idle-routing yield</td><td class="c-behavior"><code>pickIdlerIndex</code> yields to strictly-idler serving sibling, ignores unhealthy/non-serving</td><td class="c-source"><code>src/worker.ts (findIdlerCompetitor)</code></td><td class="c-test">worker/idle-routing.test.ts (9)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Services &amp; DB"><td class="c-id"><code>SVC-019</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">answerSubtaskAsk / subtask resume</td><td class="c-behavior">answers a subtask ASK; parent requeue when all subtasks done</td><td class="c-source"><code>src/worker.ts, repository.requeueParentJobIfAllSubtasksDone</code></td><td class="c-test">(none direct in scope)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">repo helper <code>requeueParentJobIfAllSubtasksDone</code> + worker <code>answerSubtaskAsk</code> untested</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-020</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Worker shutdown hooks</td><td class="c-behavior">installs + drains shutdown hooks in order, tolerates throwing hook</td><td class="c-source"><code>src/worker.ts / worker-bootstrap</code></td><td class="c-test">worker-bootstrap.test.ts (4)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-021</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Worker metrics</td><td class="c-behavior">counters increment on labelled inc; terminal-status mapping complete</td><td class="c-source"><code>src/worker.ts</code></td><td class="c-test">worker.metrics.test.ts (3)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-022</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">WorkerManager differential rebuild</td><td class="c-behavior">rebuild on config change: keep busy/unchanged worker, retire changed/removed without requeue, prune retired</td><td class="c-source"><code>src/worker-manager.ts</code></td><td class="c-test">worker-manager.test.ts (8)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">live-job protection + def-signature diff covered</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-023</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">WorkerManager shutdown requeue</td><td class="c-behavior">requeues running jobs only on shutdown when worker fails to drain; not on clean shutdown</td><td class="c-source"><code>src/worker-manager.ts</code></td><td class="c-test">worker-manager.test.ts (2 shutdown cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Services &amp; DB"><td class="c-id"><code>SVC-024</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Worker dep injection setters</td><td class="c-behavior">setMcpDeps/setWorkerMetrics/setSkillCatalog/setPushService wire shared deps</td><td class="c-source"><code>src/worker-manager.ts</code></td><td class="c-test">(none direct)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">low-risk wiring; uncovered</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-025</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Scheduler cron conversion</td><td class="c-behavior"><code>convertToCron</code> maps daily/weekly/monthly→cron; passes cron through; once special-cased</td><td class="c-source"><code>src/scheduler.ts</code></td><td class="c-test">scheduler.test.ts (convertToCron 5 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-026</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">calcNextRun / toSqliteDatetime</td><td class="c-behavior">next-run from cron; SQLite-compatible datetime; once→null</td><td class="c-source"><code>src/scheduler.ts</code></td><td class="c-test">scheduler.test.ts (calcNextRun 3 + toSqliteDatetime 2)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-027</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Scheduler skip-on-in-progress</td><td class="c-behavior">skips when prior job running/waiting_subtasks; starts new on waiting_human</td><td class="c-source"><code>src/scheduler.ts (IN_PROGRESS_STATUSES)</code></td><td class="c-test">scheduler.test.ts (4 skip cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">waiting_human-no-longer-blocks asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-028</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Schedule ownership inheritance</td><td class="c-behavior">propagates ownerId/visibility/scopeOrg + space_id to spawned local_task; NULL owner for system</td><td class="c-source"><code>src/scheduler.ts (executeScheduledTask)</code></td><td class="c-test">scheduler.test.ts (multiple ownership + space-bound vs personal cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-029</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Scheduled pieceName / classifier</td><td class="c-behavior">falls back to classifier when pieceName unset; preserves explicit pieceName</td><td class="c-source"><code>src/scheduler.ts</code></td><td class="c-test">scheduler.test.ts (pieceName cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>SVC-030</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Scheduled script (task_kind=script)</td><td class="c-behavior">runs browser-macro/script, writes logs to runtimeDir + output to space tree; failure paths; gate+allowlist</td><td class="c-source"><code>src/scheduler.ts (executeScriptScheduledTask)</code></td><td class="c-test">scheduler.test.ts (script cases incl. gate disabled, not-in-allowlist, audit row, missing script, null scriptName guard)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Services &amp; DB"><td class="c-id"><code>SVC-031</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">executeById manual run</td><td class="c-behavior">runs a scheduled task on demand</td><td class="c-source"><code>src/scheduler.ts</code></td><td class="c-test">(none direct)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">manual-trigger path untested</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-001</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">transformKeys snake↔camel</td><td class="c-behavior">toCamel/toSnake/toSnakeKeys round-trip incl. nested/arrays</td><td class="c-source"><code>src/config.ts</code></td><td class="c-test">config.test.ts (toSnakeKeys 5 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-002</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">loadConfig provider.retry</td><td class="c-behavior">loads from YAML or default; deprecated profiles→roles shim; proxy/proxyType defaults</td><td class="c-source"><code>src/config.ts</code></td><td class="c-test">config.test.ts (provider.retry + roles/proxy cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-003</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">validateConfig</td><td class="c-behavior">rejects bad concurrency/maxMovements/ask/maxStreamMinutes/maxJobMinutes/maxDepth/retry/worker fields/proxy type</td><td class="c-source"><code>src/config.ts</code></td><td class="c-test">config.test.ts (~35 validateConfig cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">exhaustive boundary coverage</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-004</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Env overrides (config.ts)</td><td class="c-behavior">OLLAMA_BASE_URL/OLLAMA_MODEL/CONCURRENCY/WORKTREE_DIR/AAO_* override loaded config</td><td class="c-source"><code>src/config.ts</code></td><td class="c-test">config.audit-regression.test.ts (OLLAMA_BASE_URL targets executed worker only, leaves persisted llm + extra workers)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only OLLAMA_BASE_URL asserted; CONCURRENCY/WORKTREE_DIR/OLLAMA_MODEL/AAO_* overrides untested</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-005</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">normalizeConfig version handling</td><td class="c-behavior">v2 pass-through; missing→v1; unsupported version throws; null input→empty</td><td class="c-source"><code>src/config-normalize.ts</code></td><td class="c-test">config-normalize.test.ts (version handling, ~6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-006</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">normalizeConfig v1→v2 llm</td><td class="c-behavior">proxy→connection_type, worker.model inheritance, single default worker, profiles→roles, retry/metrics map</td><td class="c-source"><code>src/config-normalize.ts</code></td><td class="c-test">config-normalize.test.ts (v1→v2 block, ~10)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-007</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">config-normalizer storage mirror</td><td class="c-behavior">top-level flat keys ↔ storage.* both directions; storage.* precedence (#369); worktreeDir survival</td><td class="c-source"><code>src/config-normalize.ts, src/config.ts</code></td><td class="c-test">config-normalize.test.ts (storage migration + backwards-compat, ~8); config.audit-regression.test.ts (trash_retention_days takes effect)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">precedence trap (default-merge-before-normalize) regression-guarded</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-008</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Env-ref preservation</td><td class="c-behavior">${VAR} / env: prefix preserved verbatim through normalize</td><td class="c-source"><code>src/config-normalize.ts</code></td><td class="c-test">config-normalize.test.ts (3 env-ref cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-009</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">browser.display_mode migration</td><td class="c-behavior">legacy captchaSolve=novnc → displayMode=novnc, key removed</td><td class="c-source"><code>src/config-normalize.ts (migrateBrowserDisplayMode)</code></td><td class="c-test">config-normalize.test.ts (display_mode case)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-010</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">normalize fixtures (real configs)</td><td class="c-behavior">v1-single-ollama / multi-worker-proxy / gateway-with-keys / mcp-and-ssh normalize correctly</td><td class="c-source"><code>src/config-normalize.ts</code></td><td class="c-test">config-normalize.test.ts (4 fixture cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-011</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Auth config loading</td><td class="c-behavior">parses auth section snake_case + primary_provider; undefined when absent</td><td class="c-source"><code>src/config.ts</code></td><td class="c-test">config-auth.test.ts (4)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">parsing covered; provider validation/exclusivity not asserted here</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-012</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ConfigManager read + ETag</td><td class="c-behavior">getConfig/getConfigForApi; etag from mtime; reloadFromFile</td><td class="c-source"><code>src/config-manager.ts</code></td><td class="c-test">config-manager.test.ts (load, etag, reload, masked-for-API)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-013</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ConfigManager write + CAS</td><td class="c-behavior">updateConfig writes YAML, rejects stale etag, creates file on fresh install, rejects unparseable</td><td class="c-source"><code>src/config-manager.ts</code></td><td class="c-test">config-manager.test.ts (update, stale-etag reject, fresh-install create, invalid YAML reject)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-014</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ConfigManager masking preservation</td><td class="c-behavior">masks apiKey (<code>********</code>); preserves masked field on update; matches workers/backends by id; drops mask when no prior key</td><td class="c-source"><code>src/config-manager.ts (mergeWithMaskPreservation)</code></td><td class="c-test">config-manager.test.ts (mask + preserve by-id for llm.workers + gateway.backends, drop-mask path)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-015</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ConfigManager change events</td><td class="c-behavior">emits config-changed on update</td><td class="c-source"><code>src/config-manager.ts</code></td><td class="c-test">config-manager.test.ts (&quot;emits config-changed on update&quot;)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Services &amp; DB"><td class="c-id"><code>CFG-016</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">config-manager env overrides</td><td class="c-behavior">OLLAMA_*/WORKTREE_DIR/CONCURRENCY/DB_PATH applied at runtime read</td><td class="c-source"><code>src/config-manager.ts</code></td><td class="c-test">(none direct in config-manager.test.ts)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">DB_PATH + runtime env override path untested at config-manager layer</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-017</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Server TLS / listen-port config</td><td class="c-behavior">mergeServerConfig TLS defaults, http_redirect port normalize/collision, resolveListenPort + PORT env</td><td class="c-source"><code>src/server/config.ts</code></td><td class="c-test">server/config.test.ts (24)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">(PORT/LOG_LEVEL env live here, not config.ts)</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>CFG-018</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">migrate-config CLI</td><td class="c-behavior">--help/unknown flag/missing file/dry-run/in-place+backup/already-v2/version-99</td><td class="c-source"><code>src/scripts/migrate-config.ts</code></td><td class="c-test">scripts/migrate-config.test.ts (8)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-001</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">createJob / getJob round-trip</td><td class="c-behavior">persists job incl. space_id, runtime_dir, continued_from; subtask inherits parent space_id</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">spaces-resolution.test.ts; runtime-dir.test.ts; migrate.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-002</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">claimNextJob / retry / peek</td><td class="c-behavior">role+health-aware claim; claimNextRetryJob; peekNextClaimable</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (worker-aware scheduling 2)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">claimNextRetryJob + peekNextClaimable not individually asserted</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-003</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">requeueRunningJobs / recovery</td><td class="c-behavior">requeueRunningJobs, recoverOrphanedJobs, recoverStuckRunningJobs, resumeMcpWaitingJobs</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">(worker-manager shutdown exercises requeue indirectly)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">recoverOrphaned/recoverStuck/resumeMcp* have no direct unit test</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-004</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">requestJobCancel</td><td class="c-behavior">running→cancelled; queued no-op; unknown id false</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (3)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-005</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">createLocalTask / getLocalTask</td><td class="c-behavior">persists owner/visibility/space_id/runtime_dir; reads back</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts; repository-auth.test.ts; spaces-resolution.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-006</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">listLocalTasks + latestJob/subtasks</td><td class="c-behavior">joins latest job, subtask info, ownerName/org name; ownerId filter</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (listLocalTasks suite); repository-auth.test.ts (ownerId filter)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-007</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">deleteLocalTask whitelist</td><td class="c-behavior">deletes task+jobs; refuses when running; whitelists only ephemeral/local per-task dirs; protects shared space tree</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (4); spaces-task-ops.test.ts (~9 delete cases incl. symlink/trailing-slash/null-space)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">data-loss regression strongly guarded</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-008</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">local_task_comments</td><td class="c-behavior">addLocalTaskComment, listLocalTaskComments, getUninjected/markInjected, getLatestResultComment, attachments</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (Feedback/Share); migrate.test.ts (attachments column)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">comment inject/uninject + getLatestResultComment lifecycle not directly asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-009</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">buildVisibilityWhere</td><td class="c-behavior">admin 1=1; owner/public; same-org branch; spaceColumn membership+owner OR; admin ignores spaceColumn</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">bridge/visibility.test.ts (33 incl. param-order, byte-identical back-compat)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">param ordering + admin-ignores-membership asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-010</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">canManageSpace / canEditInSpace</td><td class="c-behavior">role-gated write checks (admin/owner/member roles, viewer read-only)</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">bridge/visibility.test.ts (canManage + canEdit suites)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-011</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Spaces CRUD</td><td class="c-behavior">createSpace/getSpace/listSpaces (visibility-filtered)/updateSpace/archiveSpace</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">spaces-repository.test.ts (12)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-012</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ensurePersonalSpace invariant</td><td class="c-behavior">exactly one private personal space/user, idempotent; never hard-deletable</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">spaces-repository.test.ts (ensurePersonalSpace + hardDelete invariant)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-013</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">hardDeleteSpace crypto-shred</td><td class="c-behavior">shreds case space DEK on delete; never personal</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">spaces-repository.test.ts (crypto-shred case)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-014</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Space members CRUD + visibility</td><td class="c-behavior">add/list/getRole/updateRole/remove; membership-based read; non-member leak prevention; revocation</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.shared-space.test.ts (23)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">collaboration + leak + revocation all asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-015</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Personal space owner-only visibility</td><td class="c-behavior">even admin cannot see another user's personal space (list+get); no-auth synthetic owner path</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.personal-space-visibility.test.ts (8)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-016</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Per-space resource isolation</td><td class="c-behavior">space-A task sees only space-A MCP/SSH (not space-B/global); personal sees no case-space resources</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.spaces-resource-isolation.test.ts (4)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-017</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Space tool_policy persistence</td><td class="c-behavior">default null; round-trips JSON; clear via null</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.tool-policy.test.ts (5)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-018</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Space invites</td><td class="c-behavior">create (no-expiry default), regenerate replaces, expiry validity, revoke, FK cascade</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.space-invites.test.ts (7)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-019</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">App share links</td><td class="c-behavior">create/get/revoke/resolveAppShareToken</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.app-share.test.ts (out-of-listed but present)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">present file not fully read; assume happy-path coverage</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-020</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Tool requests + grant overlay</td><td class="c-behavior">record/list newest-first, de-dup by job/task/piece, idempotent decide, granted-tools round-trip, per-piece aggregate</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.tool-requests.test.ts (7)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-021</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">User CRUD + OAuth link</td><td class="c-behavior">createUser/getById/getByEmail/findOrCreateUserByOAuth (link by email)/listUsers/updateUser/deleteUser cascade</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository-auth.test.ts (17)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-022</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Local-auth credentials</td><td class="c-behavior">setLocalPassword/verify round-trip, per-user salt, createLocalUser email-takeover reject, upsertLocalSystemAdmin idempotent, delete refuses local user</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository-local-auth.test.ts (12)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-023</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Local orgs</td><td class="c-behavior">createLocalOrg (lorg: id), rename, member add/remove idempotent, listUserLocalOrgs, delete cascade + downgrade scoped resources</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository-local-orgs.test.ts (10)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-024</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ensureLocalUser DEK fix</td><td class="c-behavior">creates per-user row so DEK/SSH create succeeds; idempotent</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">ensure-local-user.test.ts (4)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-025</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">ScheduledTasks CRUD</td><td class="c-behavior">create/get/list/getDue/update/delete; space_id persisted (not dead-wired)</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (ScheduledTasks 5); spaces-resolution.test.ts (space_id round-trip)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-026</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Share token (local task)</td><td class="c-behavior">shareLocalTask token, idempotent, unshare clears, getByShareToken null on unknown/after-unshare</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.test.ts (Share feature 6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-027</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">addAuditLog</td><td class="c-behavior">single audit_log insert helper</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">scheduler.test.ts (user_script_run audit row asserted via path)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">addAuditLog itself not unit-tested; only one consumer asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-028</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Gateway virtual keys</td><td class="c-behavior">find/list/revoke/delete/touchLastUsed + usage</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.gateway-keys.test.ts; gateway-usage.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">(gateway-adjacent; in repo file)</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-029</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">LLM usage aggregation</td><td class="c-behavior">incrementLlmUsage (daily) + hourly + queryLlmUsageDaily + org map</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.llm-usage.test.ts; llm-usage-hourly.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-030</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Push subscriptions / prefs</td><td class="c-behavior">upsert/list/get/delete/markSuccess/markFailure; notification prefs</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">(push-service.test.ts at higher layer)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">repo-level push CRUD not directly asserted</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-031</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Calendar events</td><td class="c-behavior">getCalendarEvent/deleteCalendarEvent</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">repository.calendar.test.ts; cross-calendar.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="Services &amp; DB"><td class="c-id"><code>DB-032</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">resumeToolRequestJob</td><td class="c-behavior">resumes a job waiting on a tool grant</td><td class="c-source"><code>src/db/repository.ts</code></td><td class="c-test">(none direct)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">resume-after-grant path untested at repo layer</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-033</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Schema migrations (idempotent ALTER)</td><td class="c-behavior">runMigrations adds owner_id/continued_from/last_backend_id/attachments; idempotent</td><td class="c-source"><code>src/db/migrate.ts, schema.sql</code></td><td class="c-test">migrate.test.ts (16); migrate.space-id/ssh-space/mcp-space/reflection-columns/gateway-2b/privatize/rename tests</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">each major migration has its own test file</td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-034</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">MCP table migrations</td><td class="c-behavior">creates mcp_servers/tokens/tools/oauth_pending idempotent; BLOB cols; auth_kind default; owner index</td><td class="c-source"><code>src/db/migrate.ts</code></td><td class="c-test">migrate.test.ts (MCP suite); migrate.mcp-auth-columns.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-035</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Space backfill migrations</td><td class="c-behavior">backfillMcpServer/Ssh/BrowserSession space_ids; privatize space rows; rename personal title</td><td class="c-source"><code>src/db/migrate.ts</code></td><td class="c-test">migrate.mcp-space/ssh-space/privatize-space-rows/rename-personal-space-title.test.ts</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-036</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">resolveTaskWorkspaceDir / dirs</td><td class="c-behavior">persistent→space files tree; ephemeral→throwaway; personal-space; ensureWorkspaceDirs creates subdirs</td><td class="c-source"><code>src/spaces/workspace-resolver.ts</code></td><td class="c-test">workspace-resolver.test.ts (6); spaces-resolution.test.ts (resolveTaskFolderContext)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="Services &amp; DB"><td class="c-id"><code>DB-037</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Credential DEK encryption</td><td class="c-behavior">encrypt under SPACE DEK for space profile, OWNER DEK for personal; migration fallback; cross-DEK isolation; throws with no material</td><td class="c-source"><code>src/crypto/profile-dek.ts</code></td><td class="c-test">crypto/profile-dek.test.ts (6)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">space-vs-owner DEK separation + non-decryptability asserted</td></tr>
<tr data-cov="PARTIAL" data-area="Services &amp; DB"><td class="c-id"><code>DB-038</code></td><td class="c-area">Services &amp; DB</td><td class="c-feature">Branding storage</td><td class="c-behavior">branding config/assets read-write (gitignored data/branding)</td><td class="c-source"><code>src/bridge/branding-api.ts</code></td><td class="c-test">bridge/branding-api.test.ts</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">API-layer test exists; storage-layer branch coverage unverified</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-001</code></td><td class="c-area">UI</td><td class="c-feature">Calendar month grid + bars</td><td class="c-behavior">month grid, week split, range/time fmt, multi-day bar layout</td><td class="c-source"><code>`lib/calendar.ts`</code></td><td class="c-test"><code>lib/calendar.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-002</code></td><td class="c-area">UI</td><td class="c-feature">Command palette</td><td class="c-behavior">build/filter/group commands, hotkey gate</td><td class="c-source"><code>`lib/command-palette.ts`</code></td><td class="c-test"><code>lib/command-palette.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-003</code></td><td class="c-area">UI</td><td class="c-feature">Cron → form state</td><td class="c-behavior">parse cron string into schedule fields</td><td class="c-source"><code>`lib/cronForm.ts`</code></td><td class="c-test"><code>lib/cronForm.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only <code>cronToFormState</code> exported/tested; reverse (form→cron) lives in <code>ScheduleFields.tsx</code> untested</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-004</code></td><td class="c-area">UI</td><td class="c-feature">File move resolution</td><td class="c-behavior">basename/parentDir, resolve drag-move plan (conflicts)</td><td class="c-source"><code>`lib/fileMove.ts`</code></td><td class="c-test"><code>lib/fileMove.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-005</code></td><td class="c-area">UI</td><td class="c-feature">File type categorization</td><td class="c-behavior">split name, category, color class</td><td class="c-source"><code>`lib/fileType.ts`</code></td><td class="c-test"><code>lib/fileType.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-006</code></td><td class="c-area">UI</td><td class="c-feature">File view sort/format</td><td class="c-behavior">sort entries, toggle sort, fmt timestamp/size</td><td class="c-source"><code>`lib/fileView.ts`</code></td><td class="c-test"><code>lib/fileView.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-007</code></td><td class="c-area">UI</td><td class="c-feature">Help docs frontmatter/render</td><td class="c-behavior">split/validate frontmatter, parse, slugify, render HTML, filter</td><td class="c-source"><code>`lib/help.ts`</code></td><td class="c-test"><code>lib/help.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-008</code></td><td class="c-area">UI</td><td class="c-feature">Live-workspace auto-focus</td><td class="c-behavior">decide auto-focus on new live task</td><td class="c-source"><code>`lib/live-workspace.ts`</code></td><td class="c-test"><code>lib/live-workspace.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-009</code></td><td class="c-area">UI</td><td class="c-feature">Memory summary/filter</td><td class="c-behavior">summarize + filter memory entries by type</td><td class="c-source"><code>`lib/memorySummary.ts`</code></td><td class="c-test"><code>lib/memorySummary.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-010</code></td><td class="c-area">UI</td><td class="c-feature">Notifications logic</td><td class="c-behavior">status→event map, shouldNotify, debounce, build options</td><td class="c-source"><code>`lib/notifications.ts`</code></td><td class="c-test"><code>lib/notifications.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"><code>createNotification</code>/permission are browser-API thin wrappers (not unit-covered, acceptable)</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-011</code></td><td class="c-area">UI</td><td class="c-feature">Output-path detection/linkify</td><td class="c-behavior">regex split text by output paths, linkify escaped HTML</td><td class="c-source"><code>`lib/output-path-detect.ts`</code></td><td class="c-test"><code>lib/output-path-detect.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-012</code></td><td class="c-area">UI</td><td class="c-feature">Owner display name</td><td class="c-behavior">derive owner label</td><td class="c-source"><code>`lib/owner.ts`</code></td><td class="c-test"><code>lib/owner.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-013</code></td><td class="c-area">UI</td><td class="c-feature">Public route matching</td><td class="c-behavior">match login/join/shared public routes</td><td class="c-source"><code>`lib/publicRoute.ts`</code></td><td class="c-test"><code>lib/publicRoute.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-014</code></td><td class="c-area">UI</td><td class="c-feature">Push subscribe helpers</td><td class="c-behavior">isPushSupported/iOS/PWA, base64→Uint8Array</td><td class="c-source"><code>`lib/push-subscribe.ts`</code></td><td class="c-test"><code>lib/push-subscribe.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-015</code></td><td class="c-area">UI</td><td class="c-feature">Shared view URLs</td><td class="c-behavior">shared image base + raw url builders</td><td class="c-source"><code>`lib/sharedView.ts`</code></td><td class="c-test"><code>lib/sharedView.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-016</code></td><td class="c-area">UI</td><td class="c-feature">Sharing scope preview</td><td class="c-behavior">visibility preview text + tooltip</td><td class="c-source"><code>`lib/sharingScope.ts`</code></td><td class="c-test"><code>lib/sharingScope.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-017</code></td><td class="c-area">UI</td><td class="c-feature">Space branding vars</td><td class="c-behavior">parse color, derive brand CSS vars</td><td class="c-source"><code>`lib/spaceBranding.ts`</code></td><td class="c-test"><code>lib/spaceBranding.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-018</code></td><td class="c-area">UI</td><td class="c-feature">Space rail sort</td><td class="c-behavior">order spaces for rail</td><td class="c-source"><code>`lib/spaceSort.ts`</code></td><td class="c-test"><code>lib/spaceSort.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-019</code></td><td class="c-area">UI</td><td class="c-feature">Space task filter/count</td><td class="c-behavior">filter tasks for space, count running</td><td class="c-source"><code>`lib/spaceTasks.ts`</code></td><td class="c-test"><code>lib/spaceTasks.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-020</code></td><td class="c-area">UI</td><td class="c-feature">Split pieces (custom/default)</td><td class="c-behavior">split + resolve piece options</td><td class="c-source"><code>`lib/splitPieces.ts`</code></td><td class="c-test"><code>lib/splitPieces.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-021</code></td><td class="c-area">UI</td><td class="c-feature">Streaming field extract</td><td class="c-behavior">extract a field from partial SSE stream</td><td class="c-source"><code>`lib/streamFieldExtract.ts`</code></td><td class="c-test"><code>lib/streamFieldExtract.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-022</code></td><td class="c-area">UI</td><td class="c-feature">Tab swipe physics</td><td class="c-behavior">axis lock, resist, commit, neighbor index</td><td class="c-source"><code>`lib/tab-swipe.ts`</code></td><td class="c-test"><code>lib/tab-swipe.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-023</code></td><td class="c-area">UI</td><td class="c-feature">Task list filter/sort/group</td><td class="c-behavior">query match, group-by-status, counts, filter+sort</td><td class="c-source"><code>`lib/taskFilter.ts`</code></td><td class="c-test"><code>lib/taskFilter.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-024</code></td><td class="c-area">UI</td><td class="c-feature">Task scope filter</td><td class="c-behavior">filter tasks by scope tab</td><td class="c-source"><code>`lib/taskScope.ts`</code></td><td class="c-test"><code>lib/taskScope.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-025</code></td><td class="c-area">UI</td><td class="c-feature">Theme pref + apply</td><td class="c-behavior">resolve/store/apply theme, system-dark, events</td><td class="c-source"><code>`lib/theme.ts` (+ `theme-css-parity.test.ts`)</code></td><td class="c-test"><code>lib/theme.test.ts</code>, <code>lib/theme-css-parity.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-026</code></td><td class="c-area">UI</td><td class="c-feature">Tool policy split/patch</td><td class="c-behavior">split categories, build policy patch, count enabled</td><td class="c-source"><code>`lib/toolPolicy.ts`</code></td><td class="c-test"><code>lib/toolPolicy.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">known Bash/sensitive-tools write-path gap (MEMORY: PR #653) — verify both category + sensitiveTools systems covered</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-027</code></td><td class="c-area">UI</td><td class="c-feature">Unsaved-changes guard</td><td class="c-behavior">detect unsaved, confirm discard, hook</td><td class="c-source"><code>`lib/unsavedGuard.ts`</code></td><td class="c-test"><code>lib/unsavedGuard.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap"><code>useUnsavedGuard</code> hook portion needs jsdom; pure parts covered</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-028</code></td><td class="c-area">UI</td><td class="c-feature">URL state (useUrlState)</td><td class="c-behavior">read/build search params, legacy ?page=tasks redirect</td><td class="c-source"><code>`lib/urlState.ts`</code></td><td class="c-test"><code>lib/urlState.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">hook wrapper <code>useUrlState.ts</code> itself untested (jsdom) but pure core covered</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-029</code></td><td class="c-area">UI</td><td class="c-feature">Misc utils + activity log parse</td><td class="c-behavior">relativeTime, status tones, previewable-by-type, parseActivityLog</td><td class="c-source"><code>`lib/utils.ts`</code></td><td class="c-test"><code>lib/utils.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-030</code></td><td class="c-area">UI</td><td class="c-feature">Workspace dir role</td><td class="c-behavior">classify input/output/logs/subtasks dir</td><td class="c-source"><code>`lib/workspaceDirs.ts`</code></td><td class="c-test"><code>lib/workspaceDirs.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-031</code></td><td class="c-area">UI</td><td class="c-feature">Pet state machine</td><td class="c-behavior">pet mood/animation state transitions</td><td class="c-source"><code>`lib/pets/petState.ts`</code></td><td class="c-test"><code>lib/pets/petState.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"><code>lib/pets/toolIconMap.ts</code> is a static map (no test, low value)</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-032</code></td><td class="c-area">UI</td><td class="c-feature">i18n workspace terminology</td><td class="c-behavior">locale keys use &quot;ワークスペース&quot; consistently</td><td class="c-source"><code>`i18n/index.ts`, `i18n/locales`</code></td><td class="c-test"><code>i18n/layout-workspace.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only layout/workspace terms checked; full key-parity across locales not asserted</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-033</code></td><td class="c-area">UI</td><td class="c-feature">Service worker push handler</td><td class="c-behavior">SW push event → notification payload</td><td class="c-source"><code>`sw/push-handler.ts`</code></td><td class="c-test"><code>sw/push-handler.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-034</code></td><td class="c-area">UI</td><td class="c-feature">App share URL builder</td><td class="c-behavior">build shareable app URL/token</td><td class="c-source"><code>`components/spaces/appShareUrl.ts`</code></td><td class="c-test"><code>components/spaces/appShareUrl.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-035</code></td><td class="c-area">UI</td><td class="c-feature">App↔iframe postMessage bridge</td><td class="c-behavior">validate/route postMessage between app iframe + host</td><td class="c-source"><code>`components/spaces/app-bridge.ts`</code></td><td class="c-test"><code>components/spaces/app-bridge.test.ts</code> (31 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">heaviest pure suite; strong</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-036</code></td><td class="c-area">UI</td><td class="c-feature">App file gateway (shared/auth)</td><td class="c-behavior">route file reads through gateway, prevent 401 drift</td><td class="c-source"><code>`components/spaces/app-file-gateway.ts`</code></td><td class="c-test"><code>components/spaces/app-file-gateway.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-037</code></td><td class="c-area">UI</td><td class="c-feature">Chat detail split logic</td><td class="c-behavior">split chat vs detail pane state</td><td class="c-source"><code>`components/spaces/ChatDetailSplit.tsx` (logic)</code></td><td class="c-test"><code>components/spaces/ChatDetailSplit.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">pure split logic tested; component render not</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-038</code></td><td class="c-area">UI</td><td class="c-feature">Space detail keying</td><td class="c-behavior">stable key/identity for space detail</td><td class="c-source"><code>`components/spaces/SpaceDetail.tsx` (logic)</code></td><td class="c-test"><code>components/spaces/spaceDetailKeying.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">keying logic only</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-039</code></td><td class="c-area">UI</td><td class="c-feature">App auto-approve gate</td><td class="c-behavior">decide auto-approve of app file ops</td><td class="c-source"><code>`components/spaces/AppRunner.tsx` (logic)</code></td><td class="c-test"><code>components/spaces/AppRunner.autoapprove.test.tsx</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only auto-approve branch; AppRunner iframe lifecycle untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-040</code></td><td class="c-area">UI</td><td class="c-feature">App-share API client</td><td class="c-behavior">app-share fetch client behavior</td><td class="c-source"><code>`src/api.ts` (app-share)</code></td><td class="c-test"><code>api.app-share.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">only app-share endpoints; rest of <code>api.ts</code> untested</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-041</code></td><td class="c-area">UI</td><td class="c-feature">Harness gateway</td><td class="c-behavior">test-harness gateway (mounts real AppRunner)</td><td class="c-source"><code>`harness/`</code></td><td class="c-test"><code>harness/harness-gateway.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-042</code></td><td class="c-area">UI</td><td class="c-feature">Shared tabs (public view)</td><td class="c-behavior">which tabs show in SharedView/SharedAppView</td><td class="c-source"><code>`pages/SharedView.tsx`/`SharedAppView.tsx` (logic)</code></td><td class="c-test"><code>pages/sharedTabs.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">tab-selection logic only</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-043</code></td><td class="c-area">UI</td><td class="c-feature">Detail tab selection</td><td class="c-behavior">detail tab list / readonly gating</td><td class="c-source"><code>`components/detail/*` (logic)</code></td><td class="c-test"><code>detail/detailTabs.test.ts</code>, <code>detail/detail-readonly.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">tab + readonly logic; not full panels</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-044</code></td><td class="c-area">UI</td><td class="c-feature">Task data source resolution</td><td class="c-behavior">choose live vs shared data source for task</td><td class="c-source"><code>`components/detail/task-data-source.tsx` (logic)</code></td><td class="c-test"><code>detail/task-data-source.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-045</code></td><td class="c-area">UI</td><td class="c-feature">Console key handling</td><td class="c-behavior">terminal key → escape sequences</td><td class="c-source"><code>`detail/tabs/console/*`</code></td><td class="c-test"><code>detail/tabs/console/keys.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">key mapping only; TerminalView render/websocket untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-046</code></td><td class="c-area">UI</td><td class="c-feature">TopBar collapse logic</td><td class="c-behavior">which tabs collapse into overflow</td><td class="c-source"><code>`components/layout/TopBar.tsx` (logic)</code></td><td class="c-test"><code>layout/topbar-collapse.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">collapse math only</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-047</code></td><td class="c-area">UI</td><td class="c-feature">Settings sidebar sections</td><td class="c-behavior">section list/visibility logic</td><td class="c-source"><code>`components/settings/SettingsSidebar.tsx` (logic)</code></td><td class="c-test"><code>settings/settingsSidebar.test.ts</code></td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">section logic; per-form rendering untested</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-048</code></td><td class="c-area">UI</td><td class="c-feature">Rotating tips</td><td class="c-behavior">pick/rotate chat tip strings</td><td class="c-source"><code>`components/chat/RotatingTips.tsx` (logic)</code></td><td class="c-test"><code>chat/RotatingTips.tips.test.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-049</code></td><td class="c-area">UI</td><td class="c-feature">File drag-n-drop transfer</td><td class="c-behavior">build/read DataTransfer drag sources for files</td><td class="c-source"><code>`lib/fileDnd.ts` (`dragSources`, `readDragSources`)</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">pure, easy to test now; DnD bugs are user-visible</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-050</code></td><td class="c-area">UI</td><td class="c-feature">Files → base64</td><td class="c-behavior">convert File[] to base64 upload payload</td><td class="c-source"><code>`lib/fileBase64.ts`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">uses FileReader (jsdom/node-FileReader needed); logic small but worth a test</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-051</code></td><td class="c-area">UI</td><td class="c-feature">Local date helpers</td><td class="c-behavior">localToday, tz offset, shiftDay</td><td class="c-source"><code>`lib/localDate.ts`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">pure date math — high regression risk (tz), trivially testable</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-052</code></td><td class="c-area">UI</td><td class="c-feature">Space color</td><td class="c-behavior">derive deterministic space color</td><td class="c-source"><code>`lib/spaceColor.ts`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">pure; deterministic-output test trivial</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-053</code></td><td class="c-area">UI</td><td class="c-feature">Polling/stale constants</td><td class="c-behavior">POLLING + STALE_TIME tables</td><td class="c-source"><code>`lib/constants.ts`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">low value (constants) — skip unless invariants needed</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-054</code></td><td class="c-area">UI</td><td class="c-feature">Help content loader</td><td class="c-behavior">load+assemble help sections from raw md</td><td class="c-source"><code>`lib/help-content.ts` (`loadHelpSections`)</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap"><code>help.ts</code> parsing covered, but the loader/aggregation step is untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-055</code></td><td class="c-area">UI</td><td class="c-feature">FilePreview render (md/csv/jsonl/image/pdf)</td><td class="c-behavior">render each preview type; **relative-image rewrite** to <code>/api/local/tasks/:id/files/raw</code></td><td class="c-source"><code>`components/files/FilePreview.tsx` (`resolvedHref`, line ~320)</code></td><td class="c-test">none (only <code>lib/utils</code> <code>isPreviewable</code> flags)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">the relative-image rewrite is core, user-visible, untested. Could extract <code>resolveImageHref</code> to lib → sandbox-testable</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-056</code></td><td class="c-area">UI</td><td class="c-feature">FileBrowser (grid/list, sort, select, move)</td><td class="c-behavior">tile/detail views, multi-select, drag-move, breadcrumb</td><td class="c-source"><code>`components/files/FileBrowser.tsx` + `FileTileGrid/FileDetailList/FileBreadcrumb/MoveTargetDialog`</code></td><td class="c-test">e2e <code>spaces.spec.ts</code> (icon grid/upload/delete/multiselect/download)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">core flows covered by e2e; jsdom unit tests absent (sort/select logic is in tested libs UI-006/UI-004)</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-056b</code></td><td class="c-area">UI</td><td class="c-feature">useFileBrowser/useFileView/useFilePreview hooks</td><td class="c-behavior">data fetching + view state for files</td><td class="c-source"><code>`hooks/useFileBrowser.ts`, `useFileView.ts`, `useFilePreview.ts`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">jsdom</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-057</code></td><td class="c-area">UI</td><td class="c-feature">ChatPane / ChatMessage / MovementGroup</td><td class="c-behavior">message stream, movement grouping, tool-calls section</td><td class="c-source"><code>`components/chat/*`</code></td><td class="c-test">e2e (inline chat create/open, cancel)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">rendering untested in jsdom; movement-group grouping logic not extracted to lib</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-058</code></td><td class="c-area">UI</td><td class="c-feature">Tool-request approval card</td><td class="c-behavior">inline Approve/Deny grants + resolves request</td><td class="c-source"><code>`components/chat/ToolRequestApproval.tsx`</code></td><td class="c-test">e2e <code>tool-request.spec.ts</code> (approve, deny)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">jsdom unit absent; e2e covers both branches</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-059</code></td><td class="c-area">UI</td><td class="c-feature">Settings ConfigForm + per-section forms</td><td class="c-behavior">edit each config.yaml section (Tools/LLM/Context/Safety/SSH/MCP/Gateway/Branding/Pets/Reflection/...)</td><td class="c-source"><code>`components/settings/*Form.tsx` (~45 files)</code></td><td class="c-test">settingsSidebar (sections), toolPolicy (UI-026)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">huge surface; only sidebar + tool-policy logic tested. Per-form validation/round-trip largely untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-060</code></td><td class="c-area">UI</td><td class="c-feature">Memory &amp; Learning tab</td><td class="c-behavior">edit memory entries, reflection history + revert</td><td class="c-source"><code>`components/settings/MemoryLearningForm.tsx`, `ReflectionForm.tsx`</code></td><td class="c-test">e2e <code>spaces.spec.ts</code> (memory persists A vs B), memorySummary lib (UI-009)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">persistence covered by e2e; revert button + reflection-history list render untested</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-061</code></td><td class="c-area">UI</td><td class="c-feature">Usage dashboard</td><td class="c-behavior">per-user daily LLM usage charts</td><td class="c-source"><code>`components/usage/UsagePage.tsx`, `settings/GatewayKeyUsagePanel.tsx`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">jsdom; aggregation/format logic not extracted to lib</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-062</code></td><td class="c-area">UI</td><td class="c-feature">Worker/Node status widgets</td><td class="c-behavior">rail GPU panel, per-backend rows, worker pills</td><td class="c-source"><code>`components/dashboard/*Widget.tsx`, `hooks/useWorkerStatus/useNodeStatus`</code></td><td class="c-test">e2e <code>spaces.spec.ts</code> (GPU panel toggle, proxy per-backend rows)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">e2e smoke; widget render/empty-state untested in unit</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-063</code></td><td class="c-area">UI</td><td class="c-feature">Browser session panel + PiP</td><td class="c-behavior">live browser session view, picture-in-picture</td><td class="c-source"><code>`components/browser/*`, `hooks/usePictureInPicture.ts`</code></td><td class="c-test">e2e <code>spaces.spec.ts</code> (BROWSER panel groups render)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">PiP controller untested; panel render only via e2e</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-064</code></td><td class="c-area">UI</td><td class="c-feature">SSH config/console UI</td><td class="c-behavior">console terminal, grants, audit, key rotation</td><td class="c-source"><code>`components/settings/Ssh*.tsx`, `userfolder/Ssh*.tsx`, `useConsoleSession`</code></td><td class="c-test">none (types only <code>ssh-console-types.ts</code>)</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">large untested surface; console session hook + websocket untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-065</code></td><td class="c-area">UI</td><td class="c-feature">Mobile edge-swipe / swipeable tabs</td><td class="c-behavior">edge swipe nav, tab swipe gestures</td><td class="c-source"><code>`hooks/useEdgeSwipe.ts`, `components/mobile/SwipeableTabs.tsx`</code></td><td class="c-test"><code>hooks/useEdgeSwipe.test.ts</code> (DOM — **fails in sandbox**), <code>lib/tab-swipe.ts</code> (UI-022)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">physics covered as lib; the DOM hook test exists but can't run here</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-066</code></td><td class="c-area">UI</td><td class="c-feature">Pets overlay + frame analysis</td><td class="c-behavior">pet sprite overlay, tool-spark, frame analysis</td><td class="c-source"><code>`components/pets/*`, `hooks/usePetFrameAnalysis/useActivePet`</code></td><td class="c-test"><code>lib/pets/petState.test.ts</code> (UI-031)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">state machine covered; overlay render + frame analysis untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-067</code></td><td class="c-area">UI</td><td class="c-feature">CreateTaskDialog + PromptCoach + ScheduleFields</td><td class="c-behavior">new chat/task dialog, prompt coaching, schedule builder</td><td class="c-source"><code>`components/create/*`</code></td><td class="c-test">e2e (+新規 opens dialog; ephemeral warning), cronForm lib (UI-003)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">dialog open + ephemeral warning via e2e; prompt-coach + form→cron untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-068</code></td><td class="c-area">UI</td><td class="c-feature">Monaco file editor (userfolder)</td><td class="c-behavior">edit AGENTS.md/MCP/skills/memory files</td><td class="c-source"><code>`components/userfolder/MonacoFileEditor.tsx` + panels</code></td><td class="c-test">e2e (AGENTS.md persistence, panels render)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">persistence via e2e; editor component untested in unit</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-069</code></td><td class="c-area">UI</td><td class="c-feature">Command palette UI</td><td class="c-behavior">open palette, fuzzy filter, execute command</td><td class="c-source"><code>`components/command/CommandPalette.tsx`</code></td><td class="c-test"><code>lib/command-palette.test.ts</code> (UI-002)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">all logic in lib (FULL); only the render/keyboard wiring is jsdom</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-070</code></td><td class="c-area">UI</td><td class="c-feature">Embed cards (X/Maps/YouTube/Amazon)</td><td class="c-behavior">render structured-block embeds + detail modals</td><td class="c-source"><code>`components/embed/*`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">jsdom; parsing of block data could be extracted to lib</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-071</code></td><td class="c-area">UI</td><td class="c-feature">Setup wizard</td><td class="c-behavior">first-run setup steps</td><td class="c-source"><code>`components/setup/SetupWizard.tsx`, `hooks/useSetupState`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">jsdom</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-072</code></td><td class="c-area">UI</td><td class="c-feature">Toast / notifications hooks</td><td class="c-behavior">toast queue, task notifications dispatch</td><td class="c-source"><code>`hooks/useToast.ts`, `useTaskNotifications.ts`</code></td><td class="c-test"><code>lib/notifications.test.ts</code> (UI-010)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">decision logic in lib; hook dispatch/debounce wiring untested</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-073</code></td><td class="c-area">UI</td><td class="c-feature">Spaces foundation + rail + detail tabs</td><td class="c-behavior">create case space, open detail, mobile rail↔detail</td><td class="c-source"><code>`components/spaces/*`</code></td><td class="c-test"><code>e2e/spaces.spec.ts</code> (37 cases)</td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">most thorough e2e suite (chat CRUD, files, calendar, settings, apps, members, MCP isolation)</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-074</code></td><td class="c-area">UI</td><td class="c-feature">Cross-space calendar</td><td class="c-behavior">top-bar tab, grid, month nav, per-space dots</td><td class="c-source"><code>`components/spaces/CrossSpaceCalendar.tsx`</code></td><td class="c-test"><code>e2e/calendar.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-075</code></td><td class="c-area">UI</td><td class="c-feature">Tasks→workspace migration</td><td class="c-behavior">Tasks tab removed, personal WS auto-select, legacy redirect</td><td class="c-source"><code>`App.tsx`, `lib/urlState.ts`</code></td><td class="c-test"><code>e2e/tasks.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">also lib-covered (UI-028)</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-076</code></td><td class="c-area">UI</td><td class="c-feature">Sharing scope (multi-user)</td><td class="c-behavior">manager/member/stranger visibility (200/404), invite picker</td><td class="c-source"><code>space visibility</code></td><td class="c-test"><code>e2e-auth/sharing-scope.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap">requires auth seed</td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-077</code></td><td class="c-area">UI</td><td class="c-feature">Shared space membership</td><td class="c-behavior">invite→member row+avatars, role change, removal</td><td class="c-source"><code>`components/spaces/SpaceMembersPanel.tsx`</code></td><td class="c-test"><code>e2e-auth/shared-space.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-078</code></td><td class="c-area">UI</td><td class="c-feature">Space invite links</td><td class="c-behavior">join via token, invalid/revoked states</td><td class="c-source"><code>`components/spaces/JoinSpace.tsx`</code></td><td class="c-test"><code>e2e-auth/space-invite.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="FULL" data-area="UI"><td class="c-id"><code>UI-079</code></td><td class="c-area">UI</td><td class="c-feature">Workspace file input flow</td><td class="c-behavior">input/output folders show, next-action guidance, ephemeral warning</td><td class="c-source"><code>Files tab + create dialog</code></td><td class="c-test"><code>e2e-auth/workspace-file-input.auth.spec.ts</code></td><td class="c-cov"><span class="chip cov-full">FULL</span></td><td class="c-gap"></td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-080</code></td><td class="c-area">UI</td><td class="c-feature">App-share public view</td><td class="c-behavior">login-free read-only shared app view</td><td class="c-source"><code>`pages/SharedAppView.tsx`, `SharedView.tsx`</code></td><td class="c-test">none (only lib <code>appShareUrl</code>/<code>sharedTabs</code>/<code>sharedView</code>)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">public viewer flow has no e2e; logic libs covered</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-081</code></td><td class="c-area">UI</td><td class="c-feature">Help page</td><td class="c-behavior">render help sections by category, search</td><td class="c-source"><code>`pages/HelpPage.tsx`</code></td><td class="c-test">none (lib <code>help.ts</code> FULL)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">rendering + search wiring not e2e/jsdom tested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-082</code></td><td class="c-area">UI</td><td class="c-feature">Pieces page (CRUD)</td><td class="c-behavior">list/edit custom vs default pieces, movement editor</td><td class="c-source"><code>`pages/PiecesPage.tsx`, `settings/PieceEditor.tsx`, `MovementForm.tsx`, `RulesTable.tsx`</code></td><td class="c-test">none (lib <code>splitPieces</code> FULL)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">piece editor / movement rules UI untested</td></tr>
<tr data-cov="PARTIAL" data-area="UI"><td class="c-id"><code>UI-083</code></td><td class="c-area">UI</td><td class="c-feature">Schedules page</td><td class="c-behavior">list/create scheduled tasks (5 types)</td><td class="c-source"><code>`pages/SchedulesPage.tsx`</code></td><td class="c-test">none (lib <code>cronForm</code> partial)</td><td class="c-cov"><span class="chip cov-partial">PARTIAL</span></td><td class="c-gap">schedule CRUD UI untested</td></tr>
<tr data-cov="NONE" data-area="UI"><td class="c-id"><code>UI-084</code></td><td class="c-area">UI</td><td class="c-feature">Admin / users page</td><td class="c-behavior">user CRUD, captcha, org form</td><td class="c-source"><code>`pages/UsersPage.tsx`, `AdminCaptchaPage.tsx`, `admin/*`, `settings/OrgsForm.tsx`</code></td><td class="c-test">none</td><td class="c-cov"><span class="chip cov-none">NONE</span></td><td class="c-gap">no UI tests for admin user management</td></tr>
</tbody>
</table>
</div>
</section>
<section class="card" id="gaps">
<h2>重要ギャップ (Top gaps)</h2>
<p class="muted-note">各領域インベントリの「Top gaps」を集約。最もリスクの高い未テスト挙動。</p>
<section class="gap-area"><h3>Engine core</h3>
<p>Highest-risk untested behaviors, ranked:</p>
<ol class="gap-list">
<li><strong>ENG-052 <code>classifyPiece</code> async path (NONE).</strong> Routing every task to a piece runs through this LLM call + fallback. Only the two pure helpers are tested; the wiring, the null-parse→default-piece fallback, and LLM-error handling have zero coverage. A regression here silently misroutes all tasks.</li>
<li><strong>ENG-037 WAITING_HUMAN_BROWSER / WAITING_HUMAN_TOOL_REQUEST pauses (NONE).</strong> Two human-wait sentinel branches in piece-runner have no test. A break leaves jobs hung or, worse, falsely completed — same failure class as the documented context-overflow <code>default_next</code> trap.</li>
<li><strong>ENG-039 Lessons injection + cap + lessons.jsonl (PARTIAL).</strong> Lessons appear in snapshot tests, but the core promise — accumulated lessons injected into the next movement's prompt and trimmed at 2000 chars — is not directly asserted, nor is <code>writeLessonLog</code>. Silent loss of cross-movement learning would be invisible.</li>
<li><strong>ENG-053 <code>loadAllPieceTriggers</code> (NONE).</strong> Trigger/keyword loading feeds the classifier hint layer; the custom-dir precedence and skip-on-invalid-piece branches are uncovered.</li>
<li><strong>ENG-033 cross-movement loop guard per-movement override + reset (PARTIAL) and ENG-013 mid-iteration cancel (PARTIAL).</strong> The default-revisit abort is tested, but per-movement <code>max_consecutive_revisits</code> and the counter-reset-on-movement-change branch are not — the exact knobs that prevent runaway loops. Likewise mid-work cancellation (vs pre-loop) is only covered via snapshot side effects.</li>
</ol>
</section>
<section class="gap-area"><h3>Tools</h3>
<ol class="gap-list">
<li><strong>ReadPPTX ZIP-bomb detection &amp; PPTX size limit (TOOL-015) — NONE.</strong> <code>office.ts:2124-2130</code> rejects total-uncompressed > 200MB and <code>validateOfficeFile</code> enforces the 50MB file cap, but <code>office.test.ts</code> contains no test that crafts an over-limit / high-compression-ratio PPTX. This is the only declared anti-DoS guard in the tools layer and it is completely unverified. A malformed/regressed check would silently allow a decompression bomb.</li>
<li><strong>Office file-size-limit enforcement across ReadExcel/ReadDocx/ReadPdf (TOOL-012/013/014) — PARTIAL→NONE for the limit path.</strong> Tests thoroughly cover *format-mismatch* rejection (CFB/HTML/CSV/OOXML disguises) but never exercise <code>stats.size &gt; maxSize</code> → warning emission. The <code>resolveMaxSize</code> config override (<code>tools.office_*_max_size_mb</code>) is also untested.</li>
<li><strong>index.ts dispatch order, first-match-wins, and silent-skip-on-load-failure (XCUT-001) — NONE.</strong> A single test checks the SshConsole catalog. The core invariant — that a tool name resolves to exactly one module in a fixed precedence order, and that a module failing to import is skipped without crashing dispatch — has no test. A duplicate tool name or an import regression would pass CI.</li>
<li><strong>index.ts runtime META_TOOLS injection (XCUT-002) — PARTIAL.</strong> The 20-name runtime META list in <code>index.ts:411</code> (RequestTool, MissionUpdate, all UserFolder/AppDocs tools, etc.) is what actually makes these always-available regardless of a movement's allowed_tools, yet only the smaller <code>tool-categories.ts</code> SUBSET is asserted. The two lists can drift (they already differ by design); nothing tests that the runtime injection list matches expectations.</li>
<li><strong>DownloadFile robustness (TOOL-009) — PARTIAL.</strong> Only source-vs-input routing is tested. No coverage for download size limits, redirect following, content-type handling, or failure modes — the same blast radius class as WebFetch but without WebFetch's SSRF/binary test depth.</li>
</ol>
</section>
<section class="gap-area"><h3>Pieces</h3>
<ol class="gap-list">
<li><strong>8 of 15 pieces have ZERO direct test beyond the schema-load sweep</strong> (research-sub, brainstorming, sns-research, x-ai-digest, help) and several more are only name-fixtures (chat, slide, data-process, piece-builder). No test asserts their movement/rule/transition contract or their allowed_tools allowlist. A piece could be silently broken (wrong tool name, dropped movement, bad transition graph that still parses) and only <code>loadPiece().not.toThrow()</code> would catch it — which it won't, since those are semantic not schema errors. Highest-value targets: research-sub (spawned, complex 3-movement loop), brainstorming, sns-research, x-ai-digest.</li>
<li><strong>No allowed_tools-vs-real-tool-registry consistency test.</strong> Pieces list tool names in <code>allowed_tools</code> as plain strings; nothing asserts every name in every piece resolves to an actual registered tool. A typo (or a renamed/removed tool) leaves a dead allowlist entry that silently never gets offered to the LLM — exactly the class of drift the maintenance-checklist warns about. This would be a cheap, high-leverage sweep test across all 15 pieces.</li>
<li><strong>lint-pieces.mjs (PINFRA-004) has no test.</strong> The CI gate script (file collection, exit codes 0/1/2, YAML parse-error path) is entirely untested. Its core rule is mirrored by validatePieceDef (tested), but the script's own CLI behavior — including its parse-error exit(2) and multi-file argument handling — could regress without notice.</li>
<li><strong>No transition-graph reachability / dangling-next validation for pieces.</strong> Validation only bans terminal values in rules[].next and (in the tool path) checks next ∈ movementsWAIT_SUBTASKS. But file-backed <code>loadPiece</code>/<code>validatePieceDef</code> does NOT verify that every <code>rules[].next</code> points to an existing movement, nor that <code>initial_movement</code> exists, nor that every movement is reachable. A built-in YAML with a typo'd <code>next:</code> would load fine and only fail at runtime ("Movement not found"). No test guards this for the shipped pieces.</li>
<li><strong>Per-user piece resolution propagation (PINFRA-017) is only partially tested.</strong> The known "no-auth 'local' fallback" / "custom-default separation" checklists call out worker, continue-job, scheduler, classifier, and pieces-api as all needing custom-dir threading. Only the worker load path and pieces-api authz have tests; the scheduler-spawn and continue-job (resume after ASK/subtasks) paths resolving the correct per-user custom piece are not directly asserted — a regression here would silently fall back to built-in pieces for custom-piece users.</li>
</ol>
</section>
<section class="gap-area"><h3>Reflection</h3>
<ol class="gap-list">
<li><strong>REFL-032 — applyOne per-change write-failure path (<code>decision.code='failed'</code>)</strong>: when a memory write throws mid-loop, the applier records that single change as <code>failed</code> and continues. This distinct per-change failure branch (vs runner-level <code>outcome='failed'</code>) is not clearly asserted. It is the only place a memory mutation can partially fail under the lock, so it directly affects <code>partial</code>/<code>rejected</code> outcome correctness.</li>
<li><strong>REFL-056 — space authz negative path (<code>canEditInSpace</code> → 404)</strong>: the reflection-api space-store resolution depends on <code>canEditInSpace</code>. Tests cover the space-principal happy path, but a space *member without edit rights* hitting history/revert (must 404, no leak) is not asserted as a distinct multiuser case — exactly the kind of cross-principal authz gap that whole-branch reviews catch but per-unit tests miss.</li>
<li><strong>REFL-013 / REFL-044 / REFL-045 — runner snapshot-capture + non-fatal writeSnapshot failure</strong>: the runner's <code>writeSnapshot</code>-throws-but-record-metric-anyway branch, plus the before/after capture edge cases (remove-op uses <code>merge_target</code> for the before-file name; piece-YAML pre/post capture when <code>customPath</code> doesn't yet exist), are only lightly exercised. A torn snapshot here silently degrades the revert feature.</li>
<li><strong>REFL-007 — reflection dispatch / gateway routing</strong>: <code>worker.reflection-dispatch.test.ts</code> is small (~2 cases). The gateway role-keyed routing (<code>reflectionRoutingKey</code>, <code>roleFallback:'reflection'</code>) and the "send worker API key or gateway 401s" requirement are documented in code comments but not asserted end-to-end — a deploy-shaped risk (401 storms) that unit tests don't catch.</li>
<li><strong>REFL-031 / REFL-037 — boundary branches in applier</strong>: the ">3 memory_changes truncated with WARN" hard cap and the <code>decideOutcome</code> empty-input fallback-to-<code>abstained</code> (distinct from an explicit <code>abstain_reason</code>) lack dedicated assertions. Both are silent-correctness branches: a regression would mis-drop changes or mislabel outcomes without any test going red.</li>
</ol>
</section>
<section class="gap-area"><h3>Bridge core</h3>
<ol class="gap-list">
<li><strong>APIC-016 GET /api/local/tasks/:taskId/stream (NONE)</strong> — The SSE event stream (tool-call/delta mapping, visibility gate, the <code>toolBuf</code>/event-name reconstruction logic around line 976) has zero direct tests. This is the live job-progress feed the UI depends on; a regression here is invisible to CI.</li>
<li><strong>APIC-007 PUT /api/local/tasks/:taskId/mission (NONE)</strong> — Mission update route is completely untested: no authz (owner/admin vs viewer), no validation, no persistence assertion. Only superficial string match in an unrelated auth test.</li>
<li><strong>APIC-020 office-preview HTTP route (PARTIAL)</strong> — The module is well tested, but the <code>/files/office-preview</code> endpoint wiring is not: section-enum 400, <code>canViewTask</code> gate, and the SofficeUnavailableError→503 mapping are unexercised end-to-end (only <code>handleOfficePreviewError</code> would do the 503).</li>
<li><strong>APIC-045 subtask file 403 traversal branch (PARTIAL)</strong><code>GET /subtasks/:jobId/files/*</code> has the explicit <code>resolved !== base &amp;&amp; !startsWith(base+sep)</code> → 403 escape guard, but no test drives a traversal payload to assert the 403. The containment helper is unit-tested (APIC-046) but the route-level enforcement is not.</li>
<li><strong>APIC-011 regenerate-title (PARTIAL)</strong> — Only exercised inside the spaces test in an owner-happy context; the owner/admin-only authz denial path and the missing-generator branch are not asserted. (Secondary: APIC-041 trigger happy-path, APIC-008 comments read-gate, and APIC-037/043 GET-by-id view gates are similar PARTIAL thin spots.)</li>
</ol>
</section>
<section class="gap-area"><h3>Bridge auth &amp; spaces</h3>
<p>Highest risk first (authorization-weighted):</p>
<ol class="gap-list">
<li><strong>APIS-038 Space tool-policy write under-tested (HIGH).</strong> <code>PUT /:id/tool-policy</code> gates by <code>canManageSpace</code>, but only 9 authz assertions and — per MEMORY (PR #653) — the Bash/sensitive-tool write path (<code>buildPolicyPatch</code> only walked categories, missing the <code>SENSITIVE_TOOLS</code> separate channel) was a real save-loss bug. The read/validate/render/write round-trip for sensitive tools across both delivery systems is not asserted end-to-end. A regression here silently grants/denies Bash/SSH/browser/MCP. Add an integration test that PUTs a sensitive-tool policy as a non-manager (expect 403) and as owner (expect persisted both-channels).</li>
<li><strong>APIS-052 Gateway proxy auth boundary (HIGH).</strong> <code>mountGateway</code> proxies LLM traffic; classification and config-equivalence are well tested, but the <strong>authentication of proxied requests</strong> (Bearer/API-key acceptance/rejection, 401 on bad key) is not asserted in gateway-mount.test.ts. An unauthenticated path slipping through the gateway mount would expose backend LLM spend. Deploy-order note in MEMORY (gateway→client) hints this boundary is operationally fragile.</li>
<li><strong>APIS-062 Dashboard API has no visible auth guard (MEDIUM-HIGH).</strong> <code>createDashboardApi</code> router shows no <code>requireAuth</code>/<code>requireAdmin</code> in its route definitions and the test asserts 0 authz cases. Worker/node (GPU) status may leak infra topology to unauthenticated/non-admin users. Confirm guard is applied at mount site (server.ts) and add an authz assertion.</li>
<li><strong>APIS-019 Shared-subtask wildcard traversal (MEDIUM).</strong> <code>GET /api/shared/:token/subtasks/:jobId/files/*</code> is a public token route with a wildcard path segment; containment relies on <code>isJobWithinWorkspace</code> prefix guard. No direct test asserts path-traversal/prefix-collision rejection on this wildcard. This is exactly the class fixed previously (startsWith prefix collision) — add a traversal/escape test.</li>
<li><strong>APIS-014 No-auth admin passthrough breadth (MEDIUM).</strong> When <code>authActive=false</code>, admin guards downgrade to passthrough (admin-api covered; <strong>branding adminGuard no-auth path not asserted</strong>). If a deployment runs no-auth on a shared network, branding upload/delete is fully open. Document/assert the no-auth posture for every admin route, not just user CRUD.</li>
</ol>
<p>Lower-priority partials worth noting: APIS-018/APIS-027 (per-endpoint path-escape / non-member-on-public read not asserted), APIS-032 (calendar non-editor write rejection lighter than file gates), APIS-044 (noVNC proxy upgrade authz), APIS-056 (push send delivery).</p>
</section>
<section class="gap-area"><h3>Services &amp; DB</h3>
<ol class="gap-list">
<li><strong>Job recovery &amp; resume helpers untested (DB-003, DB-032, SVC-019).</strong> <code>recoverOrphanedJobs</code>, <code>recoverStuckRunningJobs</code>, <code>resumeMcpWaitingJobs</code>, <code>resumeToolRequestJob</code>, and <code>requeueParentJobIfAllSubtasksDone</code> have no direct unit tests. These run on worker restart / subtask completion and directly affect whether jobs get stuck or double-run — exactly the class of bug seen in the config-rebuild double-execution incident. Highest-risk gap because failures are silent (stuck or duplicated jobs in prod).</li>
<li><strong>Env override matrix only partially covered (CFG-004, CFG-016).</strong> Only <code>OLLAMA_BASE_URL</code> is asserted (in config.audit-regression). <code>CONCURRENCY</code>, <code>WORKTREE_DIR</code>, <code>OLLAMA_MODEL</code>, <code>DB_PATH</code>, and the <code>AAO_*</code> metrics vars have no test. Env overrides are a documented public interface and a known footgun (storage mirror precedence already bit the project in #368/#369).</li>
<li><strong><code>answerSubtaskAsk</code> / subtask ASK plumbing (SVC-019).</strong> The path where a parent worker answers a child subtask's ASK and the parent requeues on all-subtasks-done is untested end-to-end. This is core to the <code>waiting_subtasks</code>/<code>waiting_human</code> lifecycle and has tangled state.</li>
<li><strong><code>addAuditLog</code> not unit-tested (DB-027).</strong> Only one consumer (script run) is asserted indirectly. Audit log is a security/compliance surface; no test guards the row shape or that mutating ops actually write audit rows.</li>
<li><strong>Comment injection lifecycle (DB-008).</strong> <code>getUninjectedComments</code> / <code>markCommentsInjected</code> / <code>getLatestResultComment</code> drive what the agent sees from user comments and what the user sees as the final result — no direct assertion of the inject→mark→re-query cycle, despite being on the hot path for continuation jobs.</li>
</ol>
</section>
<section class="gap-area"><h3>UI</h3>
<ol class="gap-list">
<li><strong>UI-055 FilePreview relative-image rewrite</strong> (<code>resolveImageHref</code>/<code>resolvedHref</code>, FilePreview.tsx ~L320) — core, user-visible (broken images in markdown), and <strong>currently untested</strong>. *Sandbox-testable IF the href-rewrite function is extracted to <code>lib/</code> (pure string transform); today it's inline in a component so as-is it needs jsdom.*</li>
<li><strong>UI-059 Settings forms (~45 *Form.tsx)</strong> — largest untested surface; only <code>SettingsSidebar</code> section logic + <code>toolPolicy</code> are covered. Per-form validation/round-trip is jsdom/e2e. <strong>Mitigation that IS sandbox-testable:</strong> extract each form's validation/serialization into pure helpers (the established pattern in this repo) and unit-test those.</li>
<li><strong>UI-064 SSH UI (config/console/grants/audit/rotation)</strong> — large surface, only TS types tested, no behavior tests. Console session + websocket needs e2e; <strong>grant/audit list filtering + form validation are extractable to lib (sandbox-testable).</strong></li>
<li><strong>UI-049/UI-051/UI-052 untested pure libs</strong> (<code>fileDnd</code>, <code>localDate</code>, <code>spaceColor</code>) — <strong>immediately sandbox-testable</strong>, no extraction needed. <code>localDate</code> (tz math) is the highest regression risk of the three.</li>
<li><strong>UI-026 toolPolicy Bash/sensitive-tools write-path</strong> — known real bug class (MEMORY: PR #653 fixed a save gap where <code>buildPolicyPatch</code> only walked categories and missed <code>SENSITIVE_TOOLS</code>/Bash). Test exists but should assert <strong>both</strong> the category system and the separate sensitiveTools array across read/validate/render/write. <strong>Sandbox-testable now</strong> (pure).</li>
</ol>
</section>
</section>
<footer>
MAESTRO TDD 機能テスト計画 &middot; 段階: 設計 &middot; 更新日 2026-06-25 &middot; 全 502 行
</footer>
</div>
<script>
(function(){
var search = document.getElementById('search');
var rows = Array.prototype.slice.call(document.querySelectorAll('#master-table tbody tr'));
var btns = Array.prototype.slice.call(document.querySelectorAll('.fbtn'));
var count = document.getElementById('count');
var covFilter = 'ALL';
var q = '';
// pre-cache lowercased text per row
rows.forEach(function(r){ r.__t = r.textContent.toLowerCase(); });
function apply(){
var shown = 0;
for(var i=0;i<rows.length;i++){
var r = rows[i];
var okCov = (covFilter === 'ALL') || (r.getAttribute('data-cov') === covFilter);
var okQ = !q || r.__t.indexOf(q) !== -1;
if(okCov && okQ){ r.style.display = ''; shown++; }
else { r.style.display = 'none'; }
}
count.textContent = shown + ' / ' + rows.length + ' 件表示';
}
search.addEventListener('input', function(){ q = search.value.trim().toLowerCase(); apply(); });
btns.forEach(function(b){
b.addEventListener('click', function(){
btns.forEach(function(x){ x.classList.remove('active'); });
b.classList.add('active');
covFilter = b.getAttribute('data-cov');
apply();
});
});
apply();
})();
</script>
</body>
</html>

View File

@ -5,6 +5,30 @@
---
## 0. ツールのカテゴリ区分workspace tool policy
新しいツールを追加した場合、または既存ツールのカテゴリを変更した場合は、**`src/engine/tools/tool-categories.ts`** の `MODULE_SPECS` に登録し、安全標準かセンシティブかの区分を決める。workspace tool policy はこの定義を参照して、ワークスペースごとにツールの利用可否を解決する。
- `SENSITIVE_CATEGORIES`(現在: `ssh` / `browser`)に含まれるカテゴリのツールはデフォルト無効
- `SENSITIVE_TOOLS`(現在: `Bash`)に列挙されたツールは、カテゴリが `core` であっても個別にセンシティブ扱い
- 新カテゴリは安全側(デフォルト有効)が既定。意図的にセンシティブにする場合のみ `SENSITIVE_CATEGORIES` に追加する
**対象ファイル:**
- `src/engine/tools/tool-categories.ts``MODULE_SPECS` / `SENSITIVE_CATEGORIES` / `SENSITIVE_TOOLS`
- `src/engine/tools/index.ts``tryLoadModule` でのカテゴリ指定
- `src/bridge/tools-api.ts` — カテゴリ情報を返す `/api/tools` エンドポイント
- `ui/src/content/help/16-tools.md` — カテゴリ別の概要テーブルに行を追加
**確認方法:**
```bash
# tool-categories.ts のカテゴリ定義とツール一覧が揃っているか
npx vitest run src/engine/tools/tool-categories.test.ts
# センシティブ区分が resolver に正しく伝わるかregression guard
npx vitest run src/engine/tools/resolver.test.ts
```
---
## 1. ツールモジュールを新規追加した場合
**対象ファイル:**
@ -44,6 +68,9 @@ CLAUDE.md のテーブルが古いと、Claude Code 自身が既存ツールを
新ツールが使われるべき piece を特定し、`allowed_tools` に含まれているか確認する。
CLAUDE.md のモジュールテーブルに新ツール名が含まれているか確認する。
**実例: TestWorkspaceApp2026-06-24:**
`src/engine/tools/app-test.ts` に追加 → `tools/index.ts``tryLoadModule` 追加 → `src/bridge/tools-api.ts` のモジュール一覧に追加 → `pieces/workspace-app.yaml` の verify movement `allowed_tools` に追加 → `src/engine/tools/docs.ts``TOOL_DOC_ALIASES``testworkspaceapp: 'testworkspaceapp'` を追加 → `docs/tools/testworkspaceapp.md` を新規作成。
---
## 3. ツールをリネーム・削除した場合

View File

@ -2,6 +2,8 @@
ヘッドレスブラウザで Web ページを操作するツール。同一ジョブ内ではブラウザコンテキストCookie・ログイン状態が永続化される。
ログインが必要なサイトX / 管理画面など)を継続的にスクレイピングするなら、保存済みログインセッションを使うとよい。詳細は `ReadToolDoc({ name: "BrowserSessions" })`(または [browse-sessions.md](./browse-sessions.md))を参照。
## 2 つのモード
### 1. 基本モード — URL を開いてテキスト取得

View File

@ -15,12 +15,13 @@ cron の定期タスク(`scheduled_tasks`)とは別概念(予定は実行
- `title`(必須): 予定のタイトル
- `date`(必須): 開始日 `YYYY-MM-DD`(ローカル日付)
- `end_date`(任意): 終了日 `YYYY-MM-DD`。複数日にまたがる予定のとき指定。省略すると単日。`date` より前は不可
- `time`(任意): `HH:MM`。省略すると終日扱い
- `time`(任意): 開始 `HH:MM`。省略すると終日扱い
- `end_time`(任意): 終了 `HH:MM``time`(開始)があるときだけ有効。単日では開始時刻以降のみ(複数日は終了日側の時刻なので順序不問)
- `description`(任意): 補足説明
例:
```
AddCalendarEvent({ title: "定例MTG", date: "2026-07-01", time: "10:00" })
AddCalendarEvent({ title: "定例MTG", date: "2026-07-01", time: "10:00", end_time: "11:00" })
AddCalendarEvent({ title: "提出締切", date: "2026-07-10" }) // 終日
AddCalendarEvent({ title: "現地出張", date: "2026-07-10", end_date: "2026-07-12" }) // 複数日
```
@ -40,6 +41,7 @@ AddCalendarEvent({ title: "現地出張", date: "2026-07-10", end_date: "2026-07
## gotcha
- `date` / `from` / `to``YYYY-MM-DD``time``HH:MM` 厳守。形式違反はエラー。
- `date` / `from` / `to``YYYY-MM-DD``time` / `end_time``HH:MM` 厳守。形式違反はエラー。
- `end_time``time` なしには指定できない(終日の予定に終了時刻は持てない)。
- スペース未所属のタスクからは呼べない(`ctx.spaceId` 必須)。
- 予定はスペースの可視性に従う。イベント単独の可視性は持たない。

154
docs/tools/delegate.md Normal file
View File

@ -0,0 +1,154 @@
# Delegate
サブエージェントを同期的にインライン実行して、重い処理を委譲し本体のコンテキストを節約する。サブエージェントの中間ターンは親に見えないため、複雑な作業の最終結果だけを取得できる。
## 基本
```js
Delegate({
description: "ファイル群を分析",
prompt: "以下の 10 個のファイルを読み込み、各々の行数・言語・依存関係を分析して、JSON サマリーを output/file-analysis.json に書き込む。ファイルリスト: [リストを展開]"
})
```
呼び出すと、サブエージェントが自身の conversation context で独立に実行される。サブエージェントが完了success / abortedしたら、最終結果の文字列だけを返す。**サブエージェントの中間会話ターンは親に入らない** —— つまり、長い調査や複雑な分析が親のコンテキスト使用量に影響しない。
## いつ使うか
### Delegate が向いているケース
- **単一の明確で集中した委譲タスク**(「複数ファイルから要約を抽出」「重い分析を実行」「外部情報の深い調査」)
- **中間結果は不要で、最終成果物だけが欲しい**(サブエージェントの思考プロセスは必要ない)
- **親のコンテキスト節約が最優先**(サブの中間ターンが消費メモリ = 親に影響しない)
- **処理が比較的短時間で完了する見込み**
例:
- 「100 個のファイルを読んで, CSV を生成」→ delegate で委譲, 完了後に result を引き継ぐ
- 「特定キーワードで 30 件検索し, 記事要約→集約」→ 重い WebFetch も delegate 内で完結
- 「複数 PDF を OCR → テキスト抽出 → 解析」→ 前処理を delegate, 後続は親が実行
### SpawnSubTask が向いているケース
- **複数の独立したテーマ**A, B, C に分解)→ 並列実行が必須
- **サブタスク間に依存がない**A が完了してから B という制約がない)
- **各サブの成果物がそれぞれ意味を持つ**(集約不要または集約が簡単)
### Delegate が向かないケース
- **対話が必要**ASK ツールを呼ぶ)→ 子の ASK は親に bubbles up する
- **ネスト深さが 2 を超える**delegate の中から delegate, その中から delegate...
- **超長時間タスク**(非常に長くかかる処理)→ 親が完了を待ってブロックするため、焦点を絞ったタスクに限定すること
## パラメータ
| パラメータ | 型 | 必須 | 説明 |
|-----------|-----|------|------|
| `description` | string | ✅ | 委譲タスクを表す 3〜6 語の短いラベル |
| `prompt` | string | ✅ | サブエージェントへの自己完結した完全な指示 |
## prompt の書き方
**自己完結**で書く。サブエージェントは親の conversation history を見られないため:
- タスクの全背景を展開する(親の context は参照不可)
- 前提情報・ファイルリストなどを明示的に記載
- **期待する成果物を明確に**(ファイル名・形式・場所)
- 成功の定義を簡潔に述べる
❌ 「さっきの調査の続きをやって」
✅ 「以下のキーワード 3 つについて [展開], 各々のメリット・デメリット・ユースケースを調査し, output/comparison.md に Markdown 形式で整理する。セクション: 概要 / メリット / デメリット / 用途 / 参考資料"
❌ 「ファイルを分析して」
✅ 「`input/` 配下の全 .ts ファイルを読み込み, 以下を計測して output/stats.json に JSON 形式で出力: { fileName, lineCount, exports[], imports[] }"
## 結果の使い方
Delegate の result は文字列。次の movement で Read / Bash 等で参照:
```js
const delegateResult = "..."; // Delegate が返した result
// 結果ファイルを読み込む(委譲先が output/ に書いていれば)
Read({ path: "output/analysis.json" })
// またはログを参照
Bash({ command: "cat logs/activity.log | tail -20" })
```
## 制限
- **直列実行**: Delegate × N 個を呼ぶとシリアルに実行される(並列不可)。高速化は SpawnSubTask で並列化
- **ネスト深さ**: 約 2 レベルdelegate 内からさらに delegate を呼ぶのは許可だが, 3 段階目以上は危険)
- **完了待ちブロック**: 親は子が完了するまでブロックされるため、1 回の delegate は焦点を絞ること
- **ASK 必須情報**: サブエージェントが ASK を呼んだら, 親に `[delegate 要追加情報] ...` と bubble up される。親が回答を `transition({lessons: "..." })` で与える
- **ワークスペース共有**: 同じ workspace で実行されるため, output/ は親から見える。input/ も共有
## 使用例
### 例 1: ファイル分析の要約化
```js
Delegate({
description: "ソースファイル群を分析",
prompt: `
以下の 5 つのファイルを読み込み, 各々の:
- 行数
- 主要な exported 関数・クラス名
- 依存 import 数
を計測して, output/file-summary.json に以下形式で出力:
[
{ file: "path/to/file.ts", lines: 123, exports: [...], imports: 5 },
...
]
ファイル:
1. src/engine/agent-loop.ts
2. src/engine/piece-runner.ts
3. src/llm/openai-compat.ts
4. src/worker.ts
5. src/config-manager.ts
実行後, 必ず output/file-summary.json に JSON を書き込んで終了すること。
`
})
```
### 例 2: 複数 URL 検索 → 要約集約
```js
Delegate({
description: "キーワード検索と要約集約",
prompt: `
以下の 3 つのキーワードで Web 検索し, 上位 5 件ずつ fetch して要約をまとめる:
- キーワード A
- キーワード B
- キーワード C
各キーワード毎に:
1. WebSearch で 5-10 件検索
2. 各 URL を WebFetch で取得
3. テキスト要約(最大 5 行)を抽出
結果を output/search-summary.md に Markdown で出力:
# Search Results
## Keyword A
[上位 3 件の要約]
## Keyword B
[上位 3 件の要約]
## Keyword C
[上位 3 件の要約]
実行後, output/search-summary.md に結果を保存して終了。
`
})
```
## 詳細は ReadToolDoc
詳細な実装・エラーハンドリング・context 管理については `ReadToolDoc({ name: "Delegate" })` で確認可能。

View File

@ -0,0 +1,38 @@
# GetMyOrchestratorState
呼び出しユーザーの現在の Orchestrator 状態を **sanitized な Markdown スナップショット**で返すメタツール(常時利用可能、引数なし)。「自分の MCP は何が繋がっている?」「最近何を実行した?」のようなユーザー固有の質問に答える前に呼ぶ。
## 引数
なし。認証済みユーザー(`ctx.userId`)が前提。未認証ならエラーを返す。
## 出力に含まれる項目
| セクション | 内容 |
|-----------|------|
| ユーザー | id / 名前 / role |
| 最近のタスク | 直近 5 件task-id・ピース名・状態・作成日時・タイトル先頭 60 字) |
| MCP サーバー | **このタスクで実際に利用可能なものだけ**(後述)。各サーバーの認証種別 / 個人・全体 / 連携状況 |
| ユーザーフォルダ | AGENTS.md の有無とサイズ、`memory/` `scripts/` `browser-macros/` `templates/` `recordings/` の件数 |
| カスタム Piece | 自分の fork`data/users/{id}/pieces/` |
| 組み込み Piece | 名前のみ列挙 |
## 秘密情報は一切返さない
トークン・OAuth client secret・暗号化 blob などのセンシティブな値は**含まれない**。MCP は「連携済み / 未連携」の状態だけを返す(中身のトークンは出さない)。
## MCP のスコープに注意
報告される MCP サーバーは、**そのタスクの実効スペース(`ctx.spaceId`)で実際にエージェントへ公開されているものだけ**。別スペースに登録済みでも、このタスクから呼べないサーバーは「連携済み」と表示しない(旧仕様は owner スコープで列挙し、呼べないサーバーを連携済みと誤報していた)。
- 個人ワークスペース(`space_id IS NULL`)のタスク → `space_id IS NULL` のサーバー
- 個別スペースのタスク → その `space_id` のサーバー
## 制約
- サーバー側で DB 依存が注入されていない場合(`setAppDocsDeps` 未呼び出し)はエラー。
- 各クエリは best-effort で、失敗してもセクション単位で「取得失敗」を出して継続する。
## 関連
ユーザーフォルダの中身そのものは [ListUserAssets](./listuserassets.md) / [ReadUserMemory](./readusermemory.md) などで個別に参照する。

46
docs/tools/listappdocs.md Normal file
View File

@ -0,0 +1,46 @@
# ListAppDocs
MAESTRO のプロジェクト内ドキュメントを **カテゴリ別に一覧**するメタツール(常時利用可能、引数なし)。質問に答える前に、関連 doc を探すために使う。各エントリの symbolic name は [ReadAppDoc](./readappdoc.md) にそのまま渡せる。
## 引数
なし。
## 出力3 カテゴリの Markdown
| セクション | 内容 | 読み込み形式 |
|-----------|------|------------|
| Piece 一覧 | `pieces/*.yaml` の名前+ description 1 行 | `piece/<name>` |
| ドキュメント | `docs/` 配下の `.md`(後述の除外を除く) | `docs/<path>` |
| ツール参照 | `docs/tools/*.md` 全件 | `tool/<name>``ReadToolDoc` と同等) |
```
ListAppDocs()
# Piece 一覧 (`piece/<name>` で読み込み)
- `piece/chat` — 雑多な依頼に答える汎用ピース
...
# ドキュメント (`docs/<path>` で読み込み)
- `docs/mcp` — MCP 連携の概要
...
# ツール参照 (`tool/<name>` で読み込み — ReadToolDoc と同等)
- `tool/browseweb` — ヘッドレスブラウザで…
```
## 一覧から除外されるもの
内部向け・履歴的な doc はノイズになるため `docs/` セクションから除外される:
- `docs/design/`
- `docs/maintenance-checklist`
これらは `ReadAppDoc` でも基本的に読めないblock 対象)。
## 制約
- description は各ファイルの frontmatter を飛ばした最初の見出し/段落から最大 140 字を自動抽出。
- 合計エントリが 150 件を超えると、末尾に「個別取得を促す注記」が付く。
## 関連
個別の doc を読むには [ReadAppDoc](./readappdoc.md)。

View File

@ -0,0 +1,45 @@
# MissionUpdate
タスクの **Mission Brief**`goal` / `done` / `open` / `clarifications`)を更新するメタツール。`allowed_tools` に書かなくても常時利用可能META_TOOL
Mission Brief は毎 movement のシステムプロンプト冒頭に常に描画され、会話が長くなった後やステップをまたいでも消えない「参照点」になる。ユーザーも Overview タブから直接編集できる。
## いつ使うか
- **新規タスクの最初のツール呼び出しで `goal` を必ず set する。** ユーザーが最初に依頼した本質的な要件を verbatim言い換えず原文のままで固定する。後で会話が長くなっても、ここを見れば「本来何を頼まれたか」がぶれない。
- 作業の節目で `done`(完了したマイルストーン)と `open`(残作業・ブロッカー)を更新する。重複作業の防止と、次の一手の見通しに使う。
- ユーザーが途中で補足・制約を足してきたら `clarifications` に記録する(「これは壊さないで」「言語は英語で」など)。
## 引数
| 引数 | 説明 |
|------|------|
| `goal` | タスク全体のゴール。ユーザーの本質的な要件を原文で。Markdown 可 |
| `done` | これまでに完了した主要マイルストーン。箇条書き推奨 |
| `open` | 残っている作業・未解決のブロッカー。箇条書き推奨 |
| `clarifications` | ユーザーから追加された補足・制約。Markdown 可 |
すべて任意だが、最低1つは指定する必要がある全フィールド未指定はエラー
## 部分置換セマンティクス
**指定したフィールドだけ**が上書きされ、未指定のフィールドは現状のまま残る。`done` だけ渡せば `goal` は変わらない。
```js
// 冒頭: goal を固定
MissionUpdate({ goal: "売上 CSV を月次集計して棒グラフ付き PDF にする" })
// 進行中: 完了分と残りを更新goal はそのまま)
MissionUpdate({ done: "- CSV パース\n- 月次集計", open: "- PDF レンダリング" })
```
## 制約・注意
- 各フィールドは **2000 文字**で打ち切られる(超過分は `…[truncated]` 付きで切られる)。要点を簡潔に。
- 全フィールドを空文字で渡すと Mission Brief は**クリア**される。
- **サブタスクなど `local_task` に紐付かない実行コンテキストでは使えない**。その場合は no-op としてエラーを返すので、サブタスク側では呼ばないこと。
- 保存単位はタスクper-LocalTask。ジョブや movement 単位ではないので、ASK ラウンドや follow-up メッセージをまたいでも保持される。
## 関連
ステップ間で得た教訓は `transition` / `complete``lessons` フィールドで記録するMission Brief とは別系統)。

44
docs/tools/readappdoc.md Normal file
View File

@ -0,0 +1,44 @@
# ReadAppDoc
MAESTRO のプロジェクト内ドキュメント(`docs/` / `pieces/`)を **symbolic name** で読み込むメタツール(常時利用可能)。主に Help アシスタントが、概念や操作手順をユーザーに答える前のリファレンス参照に使う。
ワークスペース外の固定パスを読むため、通常の `Read` ツールでは到達できない。
## 引数
| 引数 | 必須 | 説明 |
|------|------|------|
| `name` | はい | 読みたい doc の symbolic name下記形式 |
## name の形式
| 形式 | 解決先 | 例 |
|------|--------|-----|
| `docs/<path>` | `docs/<path>.md``.md` は省略可) | `docs/mcp`, `docs/architecture` |
| `piece/<name>` | `pieces/<name>.yaml` | `piece/chat`, `piece/research` |
| `tool/<name>` | `docs/tools/<name>.md``ReadToolDoc` と同等) | `tool/browseweb` |
利用可能な doc が分からないときは、先に `ListAppDocs()` で一覧を取得する。
```js
ListAppDocs() // まず一覧を見る
ReadAppDoc({ name: "docs/mcp" }) // 該当 doc を読む
ReadAppDoc({ name: "tool/browse-sessions" })
```
## 読めないものallow-list / block
セキュリティのため、開けるのは `docs/` `pieces/` `docs/tools/` 配下に限られ、以下は拒否される:
- 内部向けトップレベル doc: `CLAUDE.md` / `AGENTS.md` / `README.md` / `architecture`
- パストラバーサル(`..` を含む nameや allow-list 外の絶対パス
## 制約
- 1 doc あたり **32KB** で打ち切り(超過分は末尾に omitted バイト数を注記)。
- 存在しない name や不正な形式はエラーを返す。エラー時は `ListAppDocs()` で正しい名前を確認すること。
## 関連
- 一覧取得は [ListAppDocs](./listappdocs.md)。
- ツール単体の詳細は `ReadToolDoc({ name: "..." })``ReadAppDoc({ name: "tool/..." })` と同じ docs/tools を読む)。

View File

@ -28,7 +28,7 @@ RequestTool を呼んでも、そのツールが**その場で使えるように
- **既に利用可能**: そのツールはこの movement で使える → 記録せず「そのまま呼んでください」と返る。
- **`requested`**: カタログに存在するがこの movement では未許可 → 設定漏れ候補として記録。
- **`unknown`**: そんなツールは存在しない(名前の誤り・能力ギャップ)→ 記録するが付与対象外
- **`unknown`**: そんなツールは存在しない(名前の誤り・能力ギャップ)→ **エラーを返す**(実在ツール名のみ要求可)。診断のため記録は残るが、承認待ちにはならない。エラーを受けたら実在するツールで進めること
## 関連

View File

@ -0,0 +1,153 @@
# TestWorkspaceApp
ワークスペース・アプリをヘッドレスブラウザで実起動し、操作・期待値検証・ファイルの変化確認を行う E2E テストツール。`workspace-app` ピースの verify ステップが自動的に呼び出す。
## 入力パラメータ
| パラメータ | 型 | 必須 | 説明 |
|-----------|-----|------|------|
| `space` | string | ✓ | テスト対象スペースの ID |
| `app` | string | ✓ | `apps/` 配下のアプリフォルダ名(パスではなく名前のみ) |
| `entry` | string | — | エントリー HTML のパス(スペースファイルルートからの相対)。省略時は `apps/{app}/index.html` |
| `seed_files` | array | — | テスト前にワークスペースへ書き込むファイル群(下記参照) |
| `steps` | array | — | ブラウザアクション列(`BrowseWebAction` と同じ形式、下記参照) |
| `expect` | array | — | 検証する期待値リスト(下記参照) |
| `timeout_ms` | number | — | ブラウザ操作全体のタイムアウト(ミリ秒、デフォルト 30000 |
### seed_files
テスト開始前にワークスペースへ書き込むファイルを指定する。アプリが読み取る入力データを事前配置するために使う。
```json
"seed_files": [
{ "path": "output/notes.md", "content": "# Hello\nworld" }
]
```
- `path` はワークスペースルートからの相対パス(`resolveAndGuard` でパストラバーサルを防ぐ)
- `content` は UTF-8 文字列
- 書き込んだファイルは `file_changes` に記録されるbytes はシード時の書き込みサイズ)
### stepsBrowseWebAction 形式)
ハーネスページ上で実行するブラウザ操作を配列で渡す。型は `BrowseWebAction` と共通。
| type | 追加フィールド | 説明 |
|------|--------------|------|
| `goto` | `url` | 指定 URL へ移動(ハーネス URL は自動で最初に追加されるため通常不要) |
| `click` | `selector` または `ref` | 要素をクリック |
| `fill` | `selector` または `ref`, `value` | フォーム入力 |
| `screenshot` | `value`(ファイル名) | スクリーンショットを撮影 |
| `getText` | — | ページのテキストを取得(期待値チェックに使われる) |
| `dumpHtml` | — | ページの HTML を取得 |
| `wait` | `ms` | 指定ミリ秒待機 |
ハーネスは起動時に自動的に `goto → wait(2000ms) → getText` を実行してからユーザー指定ステップを追加する。`goto` を先頭に追加する必要はない。
### expect期待値リスト
| kind | フィールド | 説明 |
|------|-----------|------|
| `text` | `target`CSS セレクタ), `contains` | ページテキスト全体が `contains` を含む。`target` は情報的なセレクタ(精度向上には `getText` アクションを先に実行) |
| `file` | `target`(ワークスペース相対パス), `contains` | 指定ファイルが存在し、その内容が `contains` を含む |
**重要**: `kind``"text"` または `"file"` のみ有効。他の値は常に失敗として記録される(サイレントパスなし)。`contains` が空文字列の場合も評価は行われる(空文字列はどの文字列にも含まれるため通過)。
```json
"expect": [
{ "kind": "text", "target": "[data-testid=\"status\"]", "contains": "保存OK" },
{ "kind": "file", "target": "output/notes.md", "contains": "edited-by-e2e" }
]
```
## 戻り値
```json
{
"ok": true,
"failures": [],
"screenshots": ["app-test-myapp-final.png"],
"console_errors": [],
"bridge_calls": [],
"file_changes": [
{ "path": "output/notes.md", "bytes": 14 }
]
}
```
| フィールド | 説明 |
|-----------|------|
| `ok` | `failures` が空なら `true` |
| `failures` | 失敗した期待値の説明(配列) |
| `screenshots` | 撮影したスクリーンショットのファイル名一覧 |
| `console_errors` | ブラウザコンソールのエラーメッセージ |
| `bridge_calls` | **V1 では常に `[]`**(ヘッドレス環境ではブリッジ呼び出しの観測不可) |
| `file_changes` | seed_files で書き込んだファイルの一覧と byte 数 |
## テンプレートの `e2e.example.json` を使う
`docs/examples/workspace-apps/{note-editor,data-viewer,form-input,dashboard}/e2e.example.json` にひな型が入っている。TestWorkspaceApp に渡す JSON の例として使えるので、新しいアプリを作る際はここをコピーして改変する。
### note-editor の例
```json
{
"app": "note-editor",
"seed_files": [
{ "path": "output/note.md", "content": "seeded-content" }
],
"steps": [
{ "type": "click", "selector": "[data-testid=\"load\"]" },
{ "type": "getText", "selector": "[data-testid=\"status\"]" },
{ "type": "fill", "selector": "[data-testid=\"body\"]", "value": "edited-by-e2e" },
{ "type": "click", "selector": "[data-testid=\"save\"]" },
{ "type": "getText", "selector": "[data-testid=\"status\"]" }
],
"expect": [
{ "kind": "text", "target": "[data-testid=\"status\"]", "contains": "保存OK" },
{ "kind": "file", "target": "output/note.md", "contains": "edited-by-e2e" }
]
}
```
## 注意事項(ゴッチャ)
### (a) seed_files の書き込みは本物の `output/` に入る
seed_files で書き込んだファイルはワークスペースの実際のパスに書かれる。テスト用の一時領域ではなく、`output/note.md` を指定すれば本物の `output/note.md` が変更される。テスト後のクリーンアップはユーザー(またはエージェント)が行う必要がある。
### (b) 書き込み確認ダイアログはハーネス側で自動承認される
ブラウザ内の `writeFile` / `deleteFile` ブリッジ呼び出しは、`/app-harness` ルートではダイアログが自動承認される。本番の `/ui/app/...` 上では確認ダイアログが表示されるので挙動が異なる。
### (c) UI のビルドが必要
`/app-harness` ルートは Vite でビルドされた UI から配信される。`npm run build:ui` を実行していないと 404 になり、テストが失敗する。開発中は `cd ui && npm run dev` を起動してから dev モードで試すこともできる。
### (d) bridge_calls は V1 では常に空
ヘッドレスブラウザ環境ではブリッジ呼び出し(`readFile` / `writeFile` など)の観測が技術的に困難なため、`bridge_calls` フィールドは常に `[]` を返す。ブリッジの動作確認は `expect``kind: "file"` でファイルの変化を確認することで代替する。
## ワークフロー例
```
# 1. アプリを作成
Write({ path: "apps/note-editor/index.html", content: "..." })
# 2. E2E テストで動作確認
TestWorkspaceApp({
space: ctx.spaceId,
app: "note-editor",
seed_files: [{ path: "output/test-note.md", content: "# Test" }],
steps: [
{ type: "wait", ms: 1500 },
{ type: "getText" },
{ type: "screenshot", value: "after-load.png" }
],
expect: [
{ kind: "text", target: "body", contains: "Test" }
]
})
```
テストが通れば `ok: true` が返り、failures は空になる。

View File

@ -19,26 +19,84 @@ JS/CSS、`data:` 画像のみ)。ユーザーはワークスペースの「ア
- **パスはワークスペース相対**: `output/...``apps/{name}/data/...` への書き込みは確認不要。
それ以外への書き込み・削除はユーザー確認が出る。`..`・絶対パスは拒否される。
## ブリッジ API要点
## ブリッジ APIこのヘルパーをそのまま使うこと
アプリは `window.parent.postMessage({ ...req, id }, '*')` で要求を送り、応答は `window`
`message` イベントで受け取る。応答エンベロープは **`{ id, ok: true, data }`** か
**`{ id, ok: false, error }`**。`id` を突き合わせ、`ok` を見て `data` を取り出す。
次の `call()` ヘルパーが正典実装。**改変せずそのままコピーして使う**(自己流で書くと
エンベロープを取り違えて動かない)。
```html
<script>
let seq = 0;
const pending = new Map();
function call(req) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
window.parent.postMessage({ ...req, id }, '*');
});
}
window.addEventListener('message', (e) => {
const r = e.data;
const p = pending.get(r && r.id);
if (!p) return;
pending.delete(r.id);
r.ok ? p.resolve(r.data) : p.reject(new Error(r.error));
});
</script>
```
使い方(`call()` は成功時に応答の `data` を返す):
```js
// 一意 id を付けて postMessage、message イベントで応答を id 突き合わせ。
const { entries } = await call({ type: 'listFiles', dir: 'output' }); // 一覧
const { content } = await call({ type: 'readFile', path: 'output/x.md' }); // 読み取り
await call({ type: 'writeFile', path: 'output/note.md', content: '...' }); // 書き込み
await call({ type: 'deleteFile', path: 'output/old.txt' }); // 削除(常に確認)
```
完全なプロトコル・最小ヘルパー実装・応答形式は **`docs/workspace-apps-bridge.md`** を参照。
確認なしで書ける場所は `output/` 配下と自分のアプリの `apps/{name}/data/` 配下のみ。
それ以外への `writeFile`・すべての `deleteFile` はユーザー確認ダイアログが出る。
`..`・絶対パスは拒否される。
## ひな型をコピーする
動くサンプルが `docs/examples/workspace-apps/file-note/` にある(`index.html` `app.json`)。
`output/` を一覧・閲覧し、メモを `output/note.md` に保存する自己完結アプリ。これを土台に、
依頼に合わせて改変して `apps/{name}/index.html` に Write するのが最短。
ワークスペース内の `readonly/app-templates/` に、複数のアーキタイプテンプレートが
**seed 済み**workspace-app ピース実行時に自動コピーされる。Read できる実ファイルなので、
`Glob({ pattern: "readonly/app-templates/*/index.html" })` で一覧を確認できる。
| フォルダ | 用途 |
|---------|------|
| `note-editor/` | `output/note.md` を読み書きするテキストエディタ |
| `data-viewer/` | CSV・JSONL ファイルをテーブル表示するビューア |
| `form-input/` | フォームで入力した内容を `output/` に書き出す |
| `dashboard/` | 複数ファイルの集計結果をカードで表示するダッシュボード |
各フォルダに `index.html``app.json``e2e.example.json` が入っている。
`e2e.example.json` は後述の E2E テストに直接渡せる入力例。
いちばん近いものを `Read readonly/app-templates/{名前}/index.html` で読み、
`apps/{name}/index.html` に Write するのが最短。テンプレには上記の正しいブリッジ実装が
組み込み済みなので、ファイル I/O もそのまま動く。
`apps/{name}/app.json`(任意)で一覧の表示名・説明・エントリを指定できる(無ければフォルダ名)。
## E2E テストverify ステップ)
`workspace-app` ピースの verify ステップは、作成したアプリを **`TestWorkspaceApp` ツールで自動 E2E テスト** する。
- ヘッドレスブラウザでアプリを実際に起動
- `seed_files` で指定した入力データをワークスペースに書き込んでからロード
- `actions` に沿ってクリック・入力・スクリーンショット操作を実行
- `expect` 条件(テキスト含有・ファイル存在・要素可視)を検証
テストに通過した場合のみ verify ステップを完了とする。失敗した場合はエラー内容と
スクリーンショットが返るので、それをもとに HTML を修正して再実行する。
E2E の入力例は各テンプレートの `e2e.example.json` を参照。詳細な使い方は
`ReadToolDoc({ name: "TestWorkspaceApp" })` で取得できる。
## 仕上げ
- 書き込んだら、ユーザーに「アプリ」タブの「開く」で起動できることを一言伝える。

View File

@ -9,7 +9,7 @@ GUI です。ワークスペースの **「アプリ」タブ**に一覧表示
> **エージェントへ**: ユーザーから「ワークスペース・アプリを作って」と頼まれたら、この仕様に
> 従った**自己完結 HTML** を `apps/{name}/index.html` に Write してください(外部リソース禁止・
> インライン JS/CSS のみ)。動くひな型は `docs/examples/workspace-apps/file-note/``index.html`
> インライン JS/CSS のみ)。動くひな型は `docs/examples/workspace-apps/note-editor/``index.html`
> `app.json`)。この仕様自体は `ReadToolDoc({ name: "workspace-apps" })` でいつでも読めます。
## 実行環境(重要な制約)

506
package-lock.json generated
View File

@ -64,6 +64,7 @@
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"fast-check": "^3.23.2",
"jsdom": "^29.1.1",
"jszip": "^3.10.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
@ -74,6 +75,210 @@
"node": ">=22"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
"integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -158,6 +363,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fast-csv/format": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz",
@ -2362,6 +2585,16 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@ -2791,6 +3024,20 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@ -2809,6 +3056,20 @@
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@ -2824,6 +3085,13 @@
"ms": "2.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@ -3776,6 +4044,19 @@
"node": ">=16.9.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@ -4001,6 +4282,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -4028,6 +4316,60 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@ -4544,6 +4886,16 @@
"underscore": "^1.13.1"
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@ -4596,6 +4948,13 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -4916,6 +5275,32 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -5275,6 +5660,16 @@
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@ -6039,6 +6434,13 @@
"node": ">=6.6.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@ -6120,6 +6522,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz",
"integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.4"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz",
"integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==",
"dev": true,
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@ -6138,6 +6560,32 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
@ -6978,6 +7426,19 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
@ -6997,6 +7458,41 @@
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -7056,6 +7552,16 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",

View File

@ -84,6 +84,7 @@
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"fast-check": "^3.23.2",
"jsdom": "^29.1.1",
"jszip": "^3.10.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",

View File

@ -93,7 +93,7 @@ movements:
- GetPiece
# META_TOOLS が自動追加: ReadToolDoc, CreateChecklist, CheckItem, GetChecklist,
# MissionUpdate, ListUserAssets, RunUserScript, UpdateUserMemory, ReadUserMemory,
# ReadUserTemplate, RenderUserTemplate, WriteUserScript, WriteUserTemplate,
# ReadUserAgents, UpdateUserAgents, WriteUserScript,
# Brainstorm, ReadAppDoc, ListAppDocs, GetMyOrchestratorState
# default_next: タスク終了は complete ツールで行うため engine-internal sentinel
default_next: COMPLETE

View File

@ -0,0 +1,66 @@
name: sns-deep-sweep
description: |
SNS タイムラインの多数の投稿を、選別した上で1件ずつクリーンな文脈のサブエージェントで
個別に深掘りし、最後に統合レポートにまとめる。
選ぶべき場合: 「タイムラインのツイートをまとめて個別に深掘り」「複数の投稿を同じ精度で
1件ずつ詳しく調べて統合してほしい」
選ぶべきでない場合: 単一トピックについて SNS の声を調べる → sns-research /
一般的な Web 調査・レポート作成 → research
triggers:
keywords: ["深掘り", "タイムライン", "まとめて", "個別に", "1件ずつ", "スイープ", "sweep", "一件ずつ"]
max_movements: 999
initial_movement: orchestrate
movements:
- name: orchestrate
edit: true
persona: researcher
instruction: |
## このタスクの進め方(オーケストレーター)
あなたは「SNS 深掘りスイープ」のオーケストレーター。タイムラインの多数の投稿を、
1件ずつ独立したサブエージェントdelegateにクリーンな文脈で深掘りさせ、最後に
統合レポートにまとめる。重い調査は各サブに任せ、あなた自身の文脈は軽く保つ。
### 1. 取得(手短に)
タスク本文の指定(対象アカウント・キーワード・期間・件数)に従って投稿を集める。
- ホームタイムライン → XTimeline
- キーワード → XSearch
- 特定アカウント → XUserPosts
取得が空・失敗ならその事実を output/triage.md に記録する(内部知識で投稿を捏造しない)。
続行できないほど取得できない場合は
complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."}) を呼ぶ。
### 2. 選別
挨拶・広告・重複・無内容を除き、議論する価値のある投稿を選ぶ。
- タスクに件数指定「上位20件」等があればそれに従う。
- 指定がなければ 15 件を超えない。
選んだ投稿の一覧と選定理由を output/triage.md に短く書く。ここで長考しない。
### 3. 深掘り委譲(選んだ各投稿に delegate を1回ずつ・直列
選んだ投稿それぞれについて、delegate を1回呼ぶ。1件ずつ順番に処理する。
選んだ投稿に上から 1 始まりの整数で連番を振り1, 2, 3 ...)、
ファイル名は tweet-1.md, tweet-2.md ... とする(ゼロ埋めしない)。
delegate の prompt には必ず次を含める:
- 対象投稿の本文・著者・URLまたは id
- 「スレッド展開XPostDetail・リンク先記事WebFetch・著者の関連投稿XUserPosts
関連 Web 検索WebSearchで裏を取る」指示
- その連番と「事実 / 背景 / 論点 / 評価 / 出典 を output/deepdive/tweet-{連番}.md に Write せよ」指示
- 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」
- 「あなたは末端の調査担当。delegate は呼ぶな」
各サブの戻り値(短い要約)だけが手元に残る。深掘り本文はファイルにある。
### 4. 統合 → 終了
- Glob で output/deepdive/*.md の件数を確認する。
- 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report.md に Write する。
各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/tweet-{連番}.md) で繋ぐ。
- 仕上がったら complete({status: "success", result: ...}) を呼ぶ。
result は output/report.md の内容をベースに、ユーザー向けの最終回答として整形する。
「✅ 完了」等のメタ説明は書かず、1行目から本題を書く。
### 原則
- 1投稿につき delegate は1回。深掘りの実作業はサブに任せ、あなたは取得・選別・統合に徹する。
- 取得できなかった事実は正直に書く。データを捏造しない。
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, delegate, 'mcp__*']
default_next: COMPLETE
rules: []

View File

@ -25,17 +25,24 @@ movements:
1. **最初に `ReadToolDoc({ name: "workspace-apps" })` で作り方の仕様を取得する**
ファイル配置・postMessage ブリッジの API・制約を必ず確認すること
2. アプリ名を決める(英小文字・数字・ハイフン推奨)。
3. 自己完結 HTML を `apps/{アプリ名}/index.html` に Write で書く:
3. **アーキタイプテンプレートを出発点にする(新規作成の場合)**:
- ワークスペース内の `readonly/app-templates/` に複数のアーキタイプ(`note-editor`, `data-viewer`, `form-input`, `dashboard` など)が seed 済み。各フォルダに `index.html` が入っている。
- まず `Glob({ pattern: "readonly/app-templates/*/index.html" })` で一覧を確認し、依頼内容に最も近いものの `index.html` を Read で読み込む。そのまま `apps/{アプリ名}/index.html` に Write してから Edit で改修する。
- **テンプレートには正しく動く postMessage ブリッジ(`call()` ヘルパー)が組み込み済み**。ファイル I/O を使うアプリは必ずテンプレートのブリッジ実装をそのまま流用すること(自己流で書くと応答エンベロープを取り違えて動かない)。
- ゼロから書くよりテンプレートを改修するほうが品質・速度ともに優れる。`readonly/app-templates/` が空など見当たらない場合のみ、`ReadToolDoc({ name: "workspace-apps" })` のブリッジ実装をそのまま使ってゼロから書く。
4. 自己完結 HTML を `apps/{アプリ名}/index.html` に Write / Edit で完成させる:
- JS / CSS はインラインのみ。外部 CDN や外部ネットワークへのアクセスは禁止。
- ワークスペースのファイル I/O が必要なら、doc に従って postMessage ブリッジを使う。
- 入力フォーム・表示領域など、依頼された GUI を一通り備えた動くものにする。
4. 既存アプリの改修依頼なら、先に Read で現状の index.html を読んでから Edit する。
5. 既存アプリの改修依頼なら、先に Read で現状の index.html を読んでから Edit する。
6. **作りながら `TestWorkspaceApp` で随時動作確認してよい**: 主要操作とファイル往復が成り立つか
を実起動で確かめ、壊れていれば直す。最終判定は verify で行うが、build 中に潰せる問題は早めに潰す。
### 終了 / 遷移方法
- **作成・更新できた → verify へ**: `transition({next_step: "verify", summary: "アプリ名と概要"})`
- **依頼が曖昧で作れない**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, ReadImage]
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, ReadImage, TestWorkspaceApp]
default_next: verify
rules:
- condition: アプリの作成・更新が完了した
@ -45,26 +52,53 @@ movements:
edit: true
persona: reviewer
instruction: |
## 検証フェーズ
## 検証フェーズE2E ゲート必須)
作成した `apps/{名前}/index.html` を読み返し、最低限の品質を確認する。
作成した `apps/{名前}/index.html` を **必ず `TestWorkspaceApp` で実際に起動・操作して** 合否を判定する。
静的な HTML 読み返しは補助チェックに過ぎない。`TestWorkspaceApp` を呼ばずに合格を宣言してはならない。
### 確認観点
- ファイルが `apps/{名前}/index.html` に存在し、単体で開ける HTML になっているか
- `<script src=...>` や外部 CDN・外部ネットワーク参照が混入していないかインラインのみか
- 依頼された GUI 要素・操作が一通り入っているか
- ファイル I/O を使う場合、doc どおりの postMessage ブリッジ手順になっているか
### 手順
問題があれば Edit で直す。直しきれない設計上の問題なら build に戻す。
1. **E2E テストを実行する(必須)**:
```
TestWorkspaceApp({
app: "{アプリ名}",
seed_files: [{ path: "output/...", content: "..." }], // 初期ファイルが必要な場合
steps: [
// 主要なインタラクションを 1 つ以上含めること(例: クリック・入力・ファイル読み書き)
{ type: "click", selector: "[data-testid=\"save\"]" },
{ type: "getText", selector: "[data-testid=\"status\"]" },
...
],
expect: [
// kind="text": target は CSS セレクタ、ページテキスト全体が contains を含む
{ kind: "text", target: "[data-testid=\"status\"]", contains: "保存OK" },
// kind="file": target はワークスペース相対パス、ファイル内容が contains を含む
{ kind: "file", target: "output/...", contains: "..." }
]
})
```
- `steps` には主要 UI 操作(ボタン押下・フォーム入力など)を少なくとも 1 件含める。
- ファイル I/O を持つアプリは `expect` に `kind: "file"` を追加して output/ ラウンドトリップも確認する。
2. **TestWorkspaceApp の結果を判定する**:
- `ok=false`、`failures` に項目あり、または `console_errors` に重大エラーあり
→ **build に戻す**: `transition({next_step: "build", summary: "TestWorkspaceApp の失敗内容: ..."})` を呼ぶ
- `ok=true` かつ `failures` が空かつ `console_errors` に致命的エラーなし → 次のステップへ
3. **補助的な静的チェック**E2E 合格後に確認):
- `<script src=...>` や外部 CDN 参照が混入していないか
- 依頼された GUI 要素が一通り揃っているか
### 終了方法
- **合格**: `complete({status: "success", result: ...})` を呼ぶ。result はそのままユーザーに表示される。
作ったアプリ名・できること・「ワークスペースの『アプリ』タブから起動できる」旨を簡潔に伝える
- **E2E 合格 + 静的チェック OK**: `complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される。
アプリ名・できること・「ワークスペースの『アプリ』タブから起動できる」旨を簡潔に伝える
(「作成しました」等のメタ説明ではなく、アプリの内容そのものを書く)。
- **作り直しが必要 → build へ**: `transition({next_step: "build", summary: "具体的な指摘"})`
- **技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc]
- **E2E 不合格・設計上の問題 → build へ**: `transition({next_step: "build", summary: "具体的な失敗内容"})`
- **技術的失敗で続行不能**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, TestWorkspaceApp]
default_next: COMPLETE
rules:
- condition: 作り直しが必要
- condition: 作り直しが必要E2E 不合格または設計上の問題)
next: build

View File

@ -26,7 +26,7 @@ echo ""
# -------------------------------------------------
# 1. Playwright ブラウザバイナリ
# -------------------------------------------------
echo "[1/4] Playwright ブラウザ..."
echo "[1/5] Playwright ブラウザ..."
# playwright が要求する chromium のパスを取得
EXPECTED_PATH=$(node -e "
@ -70,7 +70,7 @@ echo ""
# -------------------------------------------------
# 2. noVNC 依存(オプション)
# -------------------------------------------------
echo "[2/4] noVNC 依存(オプション)..."
echo "[2/5] noVNC 依存(オプション)..."
NOVNC_DEPS=(Xvfb x11vnc websockify)
NOVNC_MISSING=()
@ -91,7 +91,7 @@ echo ""
# -------------------------------------------------
# 3. better-sqlite3 ネイティブモジュール
# -------------------------------------------------
echo "[3/4] ネイティブモジュール..."
echo "[3/5] ネイティブモジュール..."
if node -e "require('better-sqlite3')" 2>/dev/null; then
pass "better-sqlite3: ロード OK"
@ -104,7 +104,7 @@ echo ""
# -------------------------------------------------
# 4. 日本語フォントAnnotateImage のテキスト描画用)
# -------------------------------------------------
echo "[4/4] 日本語フォント(オプション)..."
echo "[4/5] 日本語フォント(オプション)..."
if command -v fc-list >/dev/null 2>&1; then
if fc-list :lang=ja 2>/dev/null | grep -q .; then
@ -118,6 +118,24 @@ fi
echo ""
# -------------------------------------------------
# 5. LibreOfficePowerPoint プレビューの画像化用・オプション)
# -------------------------------------------------
echo "[5/5] LibreOfficeオプション..."
# ファイルプレビューで .pptx/.ppt をスライド画像化する際、soffice で PDF 化してから
# PyMuPDF で PNG にするsrc/bridge/office-preview.ts。未導入でも他機能には影響せず、
# PowerPoint プレビューだけが「変換エンジン未導入」のダウンロード案内に切り替わる。
# Excel(.xlsx/.xlsm) プレビューは exceljsnpmで完結するので soffice 不要。
# Docker イメージでは Dockerfile が libreoffice-impress を同梱する。
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 プレビューには不要)"
fi
echo ""
# -------------------------------------------------
# サマリー
# -------------------------------------------------

View File

@ -0,0 +1,147 @@
/**
* app-harness-api.test.ts TDD for appHarnessAuthMiddleware (Task 3)
*
* 3 required cases:
* 1. Valid token + matching space req.user is synthetic viewer for that userId
* 2. Token scope spaceId requested spaceId 403
* 3. No token at all next() with req.user untouched
*
* Additional security cases:
* 4. Present but invalid/garbage token 403 (fail-closed)
* 5. Valid token but wrong space URL 403
*/
import { describe, it, expect, beforeEach } from 'vitest';
import express, { type Request, type Response } from 'express';
import supertest from 'supertest';
import { mintAppHarnessToken, verifyAppHarnessToken } from './app-harness-token.js';
import { appHarnessAuthMiddleware } from './app-harness-api.js';
// Helper: build a minimal Express app that exercises the middleware
function buildApp() {
const app = express();
app.use(appHarnessAuthMiddleware());
// Echo route: respond with serialized req.user (or null if unset)
app.get('/api/local/spaces/:id/files', (req: Request, res: Response) => {
res.json({ user: (req as any).user ?? null });
});
// Generic catch-all to confirm non-space routes pass through
app.get('/api/local/tasks', (req: Request, res: Response) => {
res.json({ user: (req as any).user ?? null });
});
return app;
}
describe('appHarnessAuthMiddleware', () => {
const SPACE_ID = 'space-abc-123';
const USER_ID = 'user-xyz-456';
// ── Case 1: valid token + matching space ───────────────────────────────────
it('injects synthetic viewer when token scope matches requested space', async () => {
const app = buildApp();
const token = mintAppHarnessToken({ userId: USER_ID, spaceId: SPACE_ID });
const res = await supertest(app)
.get(`/api/local/spaces/${SPACE_ID}/files`)
.set('x-app-token', token);
expect(res.status).toBe(200);
const user = res.body.user;
expect(user).not.toBeNull();
expect(user.id).toBe(USER_ID);
expect(user.role).toBe('user'); // NOT elevated to admin
expect(user.status).toBe('active');
expect(Array.isArray(user.orgIds)).toBe(true);
});
// Also works via query param __appToken
it('injects viewer when token passed as __appToken query param', async () => {
const app = buildApp();
const token = mintAppHarnessToken({ userId: USER_ID, spaceId: SPACE_ID });
const res = await supertest(app)
.get(`/api/local/spaces/${SPACE_ID}/files?__appToken=${token}`);
expect(res.status).toBe(200);
expect(res.body.user?.id).toBe(USER_ID);
});
// ── Case 2: space mismatch → 403 ──────────────────────────────────────────
it('returns 403 when token scope space does not match requested space', async () => {
const app = buildApp();
const token = mintAppHarnessToken({ userId: USER_ID, spaceId: 'space-OTHER' });
const res = await supertest(app)
.get(`/api/local/spaces/${SPACE_ID}/files`)
.set('x-app-token', token);
expect(res.status).toBe(403);
expect((res.body as any).user).toBeUndefined(); // no viewer injected
});
// ── Case 3: no token → pass through untouched ─────────────────────────────
it('passes through with req.user untouched when no token is present', async () => {
const app = buildApp();
const res = await supertest(app)
.get(`/api/local/spaces/${SPACE_ID}/files`);
// Route handles the request normally (200), req.user is null (not set by middleware)
expect(res.status).toBe(200);
expect(res.body.user).toBeNull();
});
// ── Security: garbage token → 403, not silent pass-through ────────────────
it('returns 403 for an invalid/garbage token (fail-closed)', async () => {
const app = buildApp();
const res = await supertest(app)
.get(`/api/local/spaces/${SPACE_ID}/files`)
.set('x-app-token', 'garbage-invalid-token-abc');
expect(res.status).toBe(403);
});
// ── Non-space routes are unaffected by the middleware ─────────────────────
it('ignores token on non-space routes (no space id in path)', async () => {
const app = buildApp();
const token = mintAppHarnessToken({ userId: USER_ID, spaceId: SPACE_ID });
const res = await supertest(app)
.get('/api/local/tasks')
.set('x-app-token', token);
// Token present but path has no space id → middleware treats it as no-token route
// and either passes through or 403 — but must NOT crash. The important thing:
// if it passes through, user stays null.
// Per spec: cannot extract space id → fail-closed (403)
expect(res.status).toBe(403);
});
// ── Security: expired token → 403, fail-closed at middleware ───────────────
it('returns 403 for a present but expired token (fail-closed)', async () => {
const app = buildApp();
// Mint with 1ms TTL
const token = mintAppHarnessToken({ userId: USER_ID, spaceId: SPACE_ID }, 1);
// Verify the token is fresh and valid before we start the test
expect(verifyAppHarnessToken(token)).not.toBeNull();
// Wait past the 1ms TTL
const until = Date.now() + 5;
while (Date.now() < until) { /* spin briefly */ }
// Now the token should be expired
expect(verifyAppHarnessToken(token)).toBeNull();
// Middleware should reject it with 403 (fail-closed)
const res = await supertest(app)
.get(`/api/local/spaces/${SPACE_ID}/files`)
.set('x-app-token', token);
expect(res.status).toBe(403);
expect((res.body as any).user).toBeUndefined(); // no viewer injected
});
});

View File

@ -0,0 +1,125 @@
/**
* app-harness-api.ts Token-auth middleware for workspace-app headless browser access (Task 3).
*
* Exported interfaces:
* appHarnessAuthMiddleware() verifies __appToken / x-app-token, injects a
* scoped synthetic viewer into req.user so existing
* viewerOf() + canEditInSpace() checks pass.
* mountAppHarnessApi() wires the middleware; Task 5 will add the static
* harness route here.
*
* Security contract:
* - Valid token + matching space req.user set to synthetic owner principal
* - Token scope spaceId requested spaceId 403, no viewer injected (fail-closed)
* - Invalid/garbage token (present but unrecognised) 403 (fail-closed)
* - Token absent next() with req.user untouched (normal auth chain applies)
* - Cannot extract space id from URL 403 (fail-closed)
* - Synthetic viewer has role='user', status='active' NOT admin, no elevation
*/
import type { Express } from 'express';
import type { RequestHandler } from 'express';
import { verifyAppHarnessToken } from './app-harness-token.js';
// Regex to extract the space id from paths like:
// /api/local/spaces/<id>/files
// /api/local/spaces/<id>/files/content
// /api/local/spaces/<id>/files/raw
// Anchored at ^ to the real API prefix and requires a boundary (/, ?, or $) after /files
// to prevent crafted paths like /x/spaces/evil/filesapi/spaces/real/files from matching.
const SPACE_FILES_RE = /^\/api\/local\/spaces\/([^/]+)\/files(?:\/|$|\?)/;
/**
* Build a synthetic Express.User that represents the token's userId as an
* ordinary (non-admin) active user. The shape must match the Express.User
* interface declared in src/bridge/auth.ts:
* id, email, name, avatarUrl, role, status, orgIds,
* defaultVisibility, defaultVisibilityOrgId
*
* viewerOf() reads req.user directly when authActive && req.user is set.
* canManageSpace() passes when space.ownerId === user.id, so the caller must
* ensure the space is owned by this userId that is the app-harness contract.
*/
function buildSyntheticViewer(userId: string): Express.User {
return {
id: userId,
email: '',
name: null,
avatarUrl: null,
role: 'user', // ordinary principal — never admin
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
} as Express.User;
}
/**
* Middleware factory call once and pass the result to app.use().
*
* Behaviour per request:
* 1. Extract token from x-app-token header or __appToken query param.
* 2. If no token next() immediately (normal auth handles the request).
* 3. Token present:
* a. Extract space id from URL. Fail-closed (403) if not extractable.
* b. Verify token. Fail-closed (403) if invalid/expired.
* c. Compare token.spaceId with URL space id. 403 on mismatch.
* d. Set req.user to synthetic viewer for token.userId, then next().
*/
export function appHarnessAuthMiddleware(): RequestHandler {
return (req, res, next) => {
// ── 1. Token extraction ─────────────────────────────────────────────────
const rawToken =
(req.headers['x-app-token'] as string | undefined) ??
(req.query['__appToken'] as string | undefined);
if (!rawToken) {
// No token: pass through untouched — normal auth chain applies.
return next();
}
// ── 2. Space id extraction ──────────────────────────────────────────────
// Use originalUrl so this works at any router-mount depth.
const urlPath = req.originalUrl ?? req.url;
const match = SPACE_FILES_RE.exec(urlPath);
if (!match) {
// Token present but URL doesn't match a space-files path → fail-closed.
res.status(403).json({ error: 'app-harness: token present but path is not a space files route' });
return;
}
const requestedSpaceId = match[1];
// ── 3. Token verification ───────────────────────────────────────────────
const payload = verifyAppHarnessToken(rawToken);
if (!payload) {
res.status(403).json({ error: 'app-harness: invalid or expired token' });
return;
}
// ── 4. Scope check ──────────────────────────────────────────────────────
if (payload.spaceId !== requestedSpaceId) {
res.status(403).json({ error: 'app-harness: token scope does not match requested space' });
return;
}
// ── 5. Inject synthetic viewer ──────────────────────────────────────────
(req as any).user = buildSyntheticViewer(payload.userId);
return next();
};
}
/**
* Wire the app-harness middleware into the Express app.
* Must be registered BEFORE mountSpaceApi / createSpaceApi so that
* req.user is populated before the per-route viewerOf() reads it.
*
* Task 5 will add the static harness HTML route here.
*/
export function mountAppHarnessApi(
app: Express,
// deps reserved for Task 5 (static harness page, token-mint endpoint, etc.)
_deps?: Record<string, unknown>,
): void {
app.use(appHarnessAuthMiddleware());
// Task 5: static harness route goes here
}

View File

@ -0,0 +1,368 @@
/**
* app-harness-browser-e2e.test.ts REAL-BROWSER E2E for the workspace-app chain.
*
* Proves the full real-browser half that the in-process supertest (Task 10) could not:
* - A real headless Chromium loads a minimal harness page
* - The harness page embeds the note-editor app in an iframe
* - The iframe communicates with the harness via the production postMessage bridge protocol
* (same message shape: {type, path, content, id} {ok, data, id})
* - The harness forwards readFile / writeFile calls to the REAL Express API
* using a real mintAppHarnessToken + appHarnessAuthMiddleware + createSpaceApi stack
* - After save, readFileSync on disk must contain "edited-by-real-browser"
*
* Environment requirements:
* - CONTAINER=1 (disables Chromium sandbox required in this environment)
* - Run: CONTAINER=1 npx vitest run src/bridge/app-harness-browser-e2e.test.ts
*
* Skip behaviour:
* - If CONTAINER=1 is not set, or Chromium launch fails for any reason, the test
* is skipped with a clear message rather than failing the normal npm test suite.
*
* Harness design:
* The test serves a SELF-CONTAINED minimal harness HTML page plain JS, no Vite build
* needed. It implements the IDENTICAL postMessage bridge protocol that the production
* harness-gateway.ts + AppRunner use. The note-editor app is embedded as an inline srcdoc
* iframe (avoids file:// cross-origin) and communicates via window.postMessage.
*
* Bridge message protocol (production-identical):
* iframe parent: { type: 'readFile'|'writeFile', path: string, content?: string, id: number }
* parent iframe: { ok: boolean, data?: any, error?: string, id: number }
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import http from 'node:http';
import express from 'express';
import { chromium, type Browser, type Page } from 'playwright';
import { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import { createSpaceApi } from '../bridge/space-api.js';
import { appHarnessAuthMiddleware } from '../bridge/app-harness-api.js';
import { mintAppHarnessToken, revokeAppHarnessToken } from '../bridge/app-harness-token.js';
// ─── Environment gate ─────────────────────────────────────────────────────────
/**
* CONTAINER=1 is required so Chromium's sandbox is disabled (bwrap absent in this
* environment). Without it, chromium.launch throws immediately.
*/
const CONTAINER = process.env['CONTAINER'] === '1';
const TEST_TIMEOUT = 60_000;
// ─── Self-contained harness HTML ─────────────────────────────────────────────
/**
* Build a self-contained harness page. This is a minimal plain-JS
* implementation of the production harness-gateway.ts + AppRunner
* postMessage bridge. It:
* 1. Embeds the note-editor app inline as an iframe srcdoc
* 2. Listens for postMessage from the iframe: {type, path, content, id}
* 3. Forwards to the real API: GET /files/content?path=&__appToken= or
* POST /files/write with JSON {path, content} + x-app-token header
* 4. Replies back to the iframe: {ok, data, error, id}
*
* The message protocol is PRODUCTION-IDENTICAL (same shape as harness-gateway.ts).
*/
function buildHarnessHtml(spaceId: string, token: string, noteEditorSrcdoc: string): string {
// Escape the srcdoc content for an HTML attribute value
const escapedSrcdoc = noteEditorSrcdoc
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>App Harness (E2E Test)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
iframe[data-testid="app-runner-frame"] { width: 100%; height: 100%; border: none; }
</style>
</head>
<body>
<iframe
data-testid="app-runner-frame"
id="app-frame"
srcdoc="${escapedSrcdoc}"
sandbox="allow-scripts allow-same-origin"
></iframe>
<script>
// Production-identical bridge: handles readFile / writeFile postMessages
// from the iframe and proxies them to the real space-files API.
const SPACE_ID = ${JSON.stringify(spaceId)};
const TOKEN = ${JSON.stringify(token)};
const BASE = '/api/local/spaces/' + SPACE_ID + '/files';
window.addEventListener('message', async function(evt) {
const msg = evt.data;
if (!msg || typeof msg.id !== 'number') return;
try {
let result;
if (msg.type === 'readFile') {
// GET /api/local/spaces/:id/files/content?path=&__appToken=
const url = BASE + '/content?path=' + encodeURIComponent(msg.path) +
'&__appToken=' + encodeURIComponent(TOKEN);
const resp = await fetch(url, {
headers: { 'x-app-token': TOKEN }
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'read failed' }));
throw new Error(err.error || 'readFile failed: ' + resp.status);
}
const content = await resp.text();
result = { content };
} else if (msg.type === 'writeFile') {
// POST /api/local/spaces/:id/files/write + x-app-token header
const url = BASE + '/write?__appToken=' + encodeURIComponent(TOKEN);
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-app-token': TOKEN
},
body: JSON.stringify({ path: msg.path, content: msg.content })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'write failed' }));
throw new Error(err.error || 'writeFile failed: ' + resp.status);
}
result = { ok: true };
} else {
throw new Error('unknown bridge type: ' + msg.type);
}
// Reply to iframe — production shape: { ok: true, data: result, id }
evt.source.postMessage({ ok: true, data: result, id: msg.id }, '*');
} catch (err) {
// Reply error — production shape: { ok: false, error: string, id }
evt.source.postMessage({ ok: false, error: String(err.message || err), id: msg.id }, '*');
}
});
</script>
</body>
</html>`;
}
// ─── Test setup ───────────────────────────────────────────────────────────────
describe.skipIf(!CONTAINER)('app-harness real-browser E2E (requires CONTAINER=1)', () => {
let dir: string;
let worktreeDir: string;
let repo: Repository;
let spaceId: string;
let ownerId: string;
let token: string;
let server: http.Server;
let serverPort: number;
let browser: Browser;
let page: Page;
let noteFilePath: string; // absolute path to output/note.md on disk
const SEED_CONTENT = 'seed-content-from-test';
beforeAll(async () => {
// ── 1. Temp workspace ────────────────────────────────────────────────────
dir = mkdtempSync(join(tmpdir(), 'ws-app-browser-e2e-'));
worktreeDir = join(dir, 'wt');
mkdirSync(worktreeDir, { recursive: true });
// ── 2. Repo + space ─────────────────────────────────────────────────────
repo = new Repository(join(dir, `${randomUUID()}.db`));
ownerId = 'test-owner-' + randomUUID();
spaceId = randomUUID();
await repo.createSpace({
id: spaceId,
kind: 'case',
title: 'E2E Browser Test Space',
ownerId,
visibility: 'private',
});
// ── 3. Seed files ───────────────────────────────────────────────────────
const filesRoot = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(join(filesRoot, 'output'), { recursive: true });
mkdirSync(join(filesRoot, 'apps', 'note-editor'), { recursive: true });
noteFilePath = join(filesRoot, 'output', 'note.md');
writeFileSync(noteFilePath, SEED_CONTENT, 'utf-8');
// Note-editor app — use the real production example
const noteEditorSrc = readFileSync(
join(
__dirname,
'../../docs/examples/workspace-apps/note-editor/index.html',
),
'utf-8',
);
writeFileSync(
join(filesRoot, 'apps', 'note-editor', 'index.html'),
noteEditorSrc,
'utf-8',
);
// ── 4. Mint token ────────────────────────────────────────────────────────
token = mintAppHarnessToken({ userId: ownerId, spaceId });
// ── 5. Express server with real middleware ────────────────────────────────
const app = express();
app.use(appHarnessAuthMiddleware());
app.use(
'/api/local/spaces',
createSpaceApi({ repo, dataRoot: worktreeDir, worktreeDir, authActive: true }),
);
// Serve the self-contained harness HTML at GET /app-harness
const harnessHtml = buildHarnessHtml(
spaceId,
token,
noteEditorSrc,
);
app.get('/app-harness', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(harnessHtml);
});
await new Promise<void>((resolve) => {
server = http.createServer(app).listen(0, '127.0.0.1', () => {
serverPort = (server.address() as any).port;
resolve();
});
});
// ── 6. Launch Chromium ───────────────────────────────────────────────────
// CONTAINER=1 means bwrap/sandbox is disabled; we must pass --no-sandbox.
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
page = await browser.newPage();
}, TEST_TIMEOUT);
afterAll(async () => {
await page?.close().catch(() => {});
await browser?.close().catch(() => {});
if (token) revokeAppHarnessToken(token);
await new Promise<void>((resolve) => server?.close(() => resolve()));
rmSync(dir, { recursive: true, force: true });
}, TEST_TIMEOUT);
// ── Test: harness page loads and iframe is present ────────────────────────
it('loads the harness page with the app-runner iframe', async () => {
const url = `http://127.0.0.1:${serverPort}/app-harness`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
// The iframe with data-testid="app-runner-frame" must be present in the DOM
await page.waitForSelector('[data-testid="app-runner-frame"]', { timeout: 10_000 });
const frame = page.locator('[data-testid="app-runner-frame"]');
expect(await frame.count()).toBe(1);
}, TEST_TIMEOUT);
// ── Test: iframe loads note-editor and auto-reads output/note.md ─────────
it('note-editor auto-loads output/note.md via postMessage bridge', async () => {
// The note-editor calls load() on startup which triggers readFile via postMessage.
// After the load, the textarea (#body) should contain the seeded text.
const iframeEl = page.locator('[data-testid="app-runner-frame"]');
const iframeHandle = await iframeEl.elementHandle({ timeout: 10_000 });
expect(iframeHandle).not.toBeNull();
const contentFrame = await iframeHandle!.contentFrame();
expect(contentFrame).not.toBeNull();
// Wait for the status span to show '読込OK' (load success)
await contentFrame!.waitForSelector('[data-testid="status"]', { timeout: 20_000 });
await contentFrame!.waitForFunction(
() => {
const el = document.querySelector('[data-testid="status"]') as HTMLElement | null;
return el && el.textContent === '読込OK';
},
{ timeout: 20_000 },
);
// The textarea must contain the seeded content
const bodyValue = await contentFrame!.locator('[data-testid="body"]').inputValue();
expect(bodyValue).toBe(SEED_CONTENT);
}, TEST_TIMEOUT);
// ── Test: save writes to disk via real API ────────────────────────────────
it('save rewrites output/note.md on disk via real postMessage bridge + real API', async () => {
const EDITED_CONTENT = 'edited-by-real-browser';
const iframeEl = page.locator('[data-testid="app-runner-frame"]');
const iframeHandle = await iframeEl.elementHandle({ timeout: 10_000 });
const contentFrame = await iframeHandle!.contentFrame();
expect(contentFrame).not.toBeNull();
// Clear textarea and type new content
const bodyLocator = contentFrame!.locator('[data-testid="body"]');
await bodyLocator.click({ timeout: 10_000 });
await bodyLocator.fill(EDITED_CONTENT, { timeout: 10_000 });
// Click save
await contentFrame!.locator('[data-testid="save"]').click({ timeout: 10_000 });
// Wait for status → '保存OK' (save success)
await contentFrame!.waitForFunction(
() => {
const el = document.querySelector('[data-testid="status"]') as HTMLElement | null;
return el && el.textContent === '保存OK';
},
{ timeout: 20_000 },
);
// ── Disk assertion ────────────────────────────────────────────────────────
const diskContent = readFileSync(noteFilePath, 'utf-8');
expect(diskContent).toBe(EDITED_CONTENT);
}, TEST_TIMEOUT);
// ── Test: no browser console errors during the round-trip ─────────────────
it('produces no browser console errors during the round-trip', async () => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
// Re-navigate to get a clean console
const url = `http://127.0.0.1:${serverPort}/app-harness`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
const iframeEl = page.locator('[data-testid="app-runner-frame"]');
const iframeHandle = await iframeEl.elementHandle({ timeout: 10_000 });
const contentFrame = await iframeHandle!.contentFrame();
// Wait for load to complete again
await contentFrame!.waitForFunction(
() => {
const el = document.querySelector('[data-testid="status"]') as HTMLElement | null;
return el && el.textContent === '読込OK';
},
{ timeout: 20_000 },
);
// Short settle
await new Promise((r) => setTimeout(r, 500));
// Filter out known non-fatal browser noise unrelated to our code
const realErrors = errors.filter(
(e) =>
!e.includes('favicon') &&
!e.includes('chrome-extension') &&
!e.includes('net::ERR_'),
);
expect(realErrors).toEqual([]);
}, TEST_TIMEOUT);
});

View File

@ -0,0 +1,239 @@
/**
* app-harness-e2e.test.ts Integration test (Approach B: in-process)
*
* Proves the workspace-app token+file-API round-trip end-to-end:
* 1. mint a real token (mintAppHarnessToken)
* 2. GET /api/local/spaces/:id/files/content?path=output/note.md via __appToken query param
* asserts: 200, seeded text returned
* 3. POST /api/local/spaces/:id/files/write via x-app-token header
* asserts: 200, bytes reported
* 4. disk assertion: readFileSync(abs) === edited text
* 5. wrong-space token 403 (containment proof)
*
* Gap explicitly noted: this does NOT drive a real iframe/browser. The
* AppRunner postMessage bridge rendering layer is not exercised here only
* the token-auth middleware + file API routes are proven. See task-10-report.md
* for manual steps to run the full Playwright round-trip.
*
* Approach B was chosen because:
* - The server requires Ollama/LLM config that is not present in this sandbox.
* - Playwright Chromium launch requires bwrap which is disabled (CONTAINER=1
* workaround only applies to the project's own BrowseWeb tool, not bare vitest).
* - The token-auth middleware and file-API are fully independent of the LLM;
* supertest exercises the real Express stack against a real SQLite DB and
* real temp files equivalent fidelity for the components under test.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import {
mkdtempSync,
rmSync,
mkdirSync,
writeFileSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import { createSpaceApi } from '../bridge/space-api.js';
import { appHarnessAuthMiddleware } from '../bridge/app-harness-api.js';
import { mintAppHarnessToken, revokeAppHarnessToken, verifyAppHarnessToken } from '../bridge/app-harness-token.js';
// ─── helpers ──────────────────────────────────────────────────────────────────
/**
* Build a minimal Express app that wires the middleware chain identically
* to the production server:
* appHarnessAuthMiddleware (populates req.user from token)
* createSpaceApi router (reads req.user via viewerOf)
*
* authActive=false: in no-auth mode viewerOf() falls back to the synthetic
* 'local' admin when req.user is unset. Setting authActive=true means
* viewerOf() uses req.user exactly so the harness middleware's synthetic
* viewer is the sole principal, matching production harness behaviour.
*/
function buildApp(repo: Repository, worktreeDir: string) {
const app = express();
// Must be before the space router so req.user is populated first.
app.use(appHarnessAuthMiddleware());
app.use('/api/local/spaces', createSpaceApi({
repo,
dataRoot: worktreeDir, // dataRoot only matters for non-file routes
worktreeDir,
authActive: true, // force viewerOf() to honour req.user
}));
return app;
}
// ─── test suite ───────────────────────────────────────────────────────────────
describe('workspace-app E2E: token-auth + file API round-trip', () => {
let dir: string;
let worktreeDir: string;
let repo: Repository;
let spaceId: string;
let ownerId: string;
let notePath: string; // absolute path on disk for assertions
let token: string;
const NOTE_SEED = '# My Note\n\noriginal content';
const NOTE_EDITED = '# My Note\n\nedited by workspace-app E2E test';
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'ws-app-e2e-'));
worktreeDir = join(dir, 'wt');
ownerId = 'user-e2e-' + randomUUID();
// Real SQLite database — same path used by production.
repo = new Repository(join(dir, `${randomUUID()}.db`));
// Create a real space owned by ownerId.
const space = await repo.createSpace({
kind: 'case',
title: 'E2E Test Space',
ownerId,
});
spaceId = space.id;
// Seed output/note.md in the space's files dir.
const filesRoot = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(join(filesRoot, 'output'), { recursive: true });
mkdirSync(join(filesRoot, 'apps', 'note-editor'), { recursive: true });
notePath = join(filesRoot, 'output', 'note.md');
writeFileSync(notePath, NOTE_SEED);
// Place a minimal note-editor app HTML (proves apps/ dir structure).
writeFileSync(
join(filesRoot, 'apps', 'note-editor', 'index.html'),
'<!DOCTYPE html><html><body>note-editor stub</body></html>',
);
// Mint a real harness token scoped to this user + space.
token = mintAppHarnessToken({ userId: ownerId, spaceId });
});
afterEach(() => {
revokeAppHarnessToken(token);
rmSync(dir, { recursive: true, force: true });
});
// ── 1. Read seeded file via __appToken query param ─────────────────────────
it('GET files/content via __appToken returns seeded note.md', async () => {
const app = buildApp(repo, worktreeDir);
const res = await request(app)
.get(`/api/local/spaces/${spaceId}/files/content`)
.query({ path: 'output/note.md', __appToken: token });
expect(res.status).toBe(200);
expect(res.text).toBe(NOTE_SEED);
});
// ── 2. Write via x-app-token header rewrites disk ─────────────────────────
it('POST files/write via x-app-token header rewrites output/note.md on disk', async () => {
const app = buildApp(repo, worktreeDir);
const res = await request(app)
.post(`/api/local/spaces/${spaceId}/files/write`)
.set('x-app-token', token)
.send({ path: 'output/note.md', content: NOTE_EDITED });
expect(res.status).toBe(200);
expect(res.body.bytes).toBe(Buffer.byteLength(NOTE_EDITED));
// ── KEY ASSERTION: disk state changed ───────────────────────────────────
const diskContent = readFileSync(notePath, 'utf-8');
expect(diskContent).toBe(NOTE_EDITED);
});
// ── 3. Full round-trip: read → edit → write → read-back ──────────────────
it('full round-trip: read seeded → write edited → GET returns new content', async () => {
const app = buildApp(repo, worktreeDir);
// Step A — read original
const readRes = await request(app)
.get(`/api/local/spaces/${spaceId}/files/content`)
.query({ path: 'output/note.md', __appToken: token });
expect(readRes.status).toBe(200);
expect(readRes.text).toBe(NOTE_SEED);
// Step B — write edited
const writeRes = await request(app)
.post(`/api/local/spaces/${spaceId}/files/write`)
.set('x-app-token', token)
.send({ path: 'output/note.md', content: NOTE_EDITED });
expect(writeRes.status).toBe(200);
// Step C — disk assertion
expect(readFileSync(notePath, 'utf-8')).toBe(NOTE_EDITED);
// Step D — read-back via API to confirm the chain is coherent
const readBackRes = await request(app)
.get(`/api/local/spaces/${spaceId}/files/content`)
.query({ path: 'output/note.md', __appToken: token });
expect(readBackRes.status).toBe(200);
expect(readBackRes.text).toBe(NOTE_EDITED);
});
// ── 4. Wrong-space token → 403 (containment) ─────────────────────────────
it('wrong-space token is rejected with 403 (containment)', async () => {
const app = buildApp(repo, worktreeDir);
// Mint a token scoped to a *different* space id.
const otherSpaceId = 'space-other-' + randomUUID();
const wrongToken = mintAppHarnessToken({ userId: ownerId, spaceId: otherSpaceId });
try {
const res = await request(app)
.get(`/api/local/spaces/${spaceId}/files/content`)
.query({ path: 'output/note.md', __appToken: wrongToken });
expect(res.status).toBe(403);
} finally {
revokeAppHarnessToken(wrongToken);
}
});
// ── 5. Write outside output/ is rejected ─────────────────────────────────
it('write to a path outside apps/ or output/ is rejected with 403', async () => {
const app = buildApp(repo, worktreeDir);
const res = await request(app)
.post(`/api/local/spaces/${spaceId}/files/write`)
.set('x-app-token', token)
.send({ path: 'AGENTS.md', content: 'malicious override' });
expect(res.status).toBe(403);
// Disk must be untouched (AGENTS.md should not exist under filesRoot).
const filesRoot = spaceFilesDir(worktreeDir, spaceId);
expect(() => readFileSync(join(filesRoot, 'AGENTS.md'))).toThrow();
});
// ── 6. Expired token is rejected ─────────────────────────────────────────
it('expired token is rejected with 403', { timeout: 10_000 }, async () => {
const app = buildApp(repo, worktreeDir);
const expiredToken = mintAppHarnessToken(
{ userId: ownerId, spaceId },
1, // 1 ms TTL → expires immediately
);
// Spin until TTL expires (matches pattern in app-harness-token.test.ts).
await new Promise<void>((resolve) => {
const poll = () => {
if (verifyAppHarnessToken(expiredToken) === null) { resolve(); } else { setTimeout(poll, 5); }
};
setTimeout(poll, 5);
});
const res = await request(app)
.get(`/api/local/spaces/${spaceId}/files/content`)
.query({ path: 'output/note.md', __appToken: expiredToken });
expect(res.status).toBe(403);
});
});

View File

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { mintAppHarnessToken, verifyAppHarnessToken, revokeAppHarnessToken } from './app-harness-token.js';
describe('app-harness-token', () => {
it('mints + verifies a scoped token', () => {
const t = mintAppHarnessToken({ userId: 'u1', spaceId: 's1' });
expect(verifyAppHarnessToken(t)).toEqual({ userId: 'u1', spaceId: 's1' });
});
it('rejects unknown/garbage tokens', () => {
expect(verifyAppHarnessToken('nope')).toBeNull();
});
it('rejects after revoke', () => {
const t = mintAppHarnessToken({ userId: 'u1', spaceId: 's1' });
revokeAppHarnessToken(t);
expect(verifyAppHarnessToken(t)).toBeNull();
});
it('rejects after expiry', () => {
const t = mintAppHarnessToken({ userId: 'u1', spaceId: 's1' }, 1);
// wait past TTL
const until = Date.now() + 5;
while (Date.now() < until) { /* spin briefly */ }
expect(verifyAppHarnessToken(t)).toBeNull();
});
});

View File

@ -0,0 +1,25 @@
import { randomBytes } from 'node:crypto';
interface TokenEntry { userId: string; spaceId: string; expiresAt: number; }
const store = new Map<string, TokenEntry>();
const DEFAULT_TTL_MS = 120_000; // 1 test = 1 token, generous
export function mintAppHarnessToken(
scope: { userId: string; spaceId: string },
ttlMs: number = DEFAULT_TTL_MS,
): string {
const token = randomBytes(24).toString('hex');
store.set(token, { ...scope, expiresAt: Date.now() + ttlMs });
return token;
}
export function verifyAppHarnessToken(token: string): { userId: string; spaceId: string } | null {
const e = store.get(token);
if (!e) return null;
if (Date.now() > e.expiresAt) { store.delete(token); return null; }
return { userId: e.userId, spaceId: e.spaceId };
}
export function revokeAppHarnessToken(token: string): void {
store.delete(token);
}

View File

@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import { mountAppShareApi } from './app-share-api.js';
function makeApp(repo: Repository, worktreeDir: string) {
const app = express();
// 公開 API は認証ミドルウェアの外側。テストでも認証なしで mount する。
mountAppShareApi(app, repo, worktreeDir);
return app;
}
describe('app-share-api公開・read-only', () => {
let repo: Repository;
let dir: string;
let worktreeDir: string;
let spaceId: string;
let token: string;
const appName = 'dashboard';
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'app-share-api-'));
worktreeDir = join(dir, 'wt');
repo = new Repository(join(dir, `${randomUUID()}.db`));
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: 'user-1' });
spaceId = space.id;
const filesRoot = spaceFilesDir(worktreeDir, spaceId);
// apps/{appName}/index.html
mkdirSync(join(filesRoot, 'apps', appName), { recursive: true });
writeFileSync(join(filesRoot, 'apps', appName, 'index.html'), '<html><body>app</body></html>');
writeFileSync(join(filesRoot, 'apps', appName, 'data.json'), '{"k":1}');
// output/ 成果物
mkdirSync(join(filesRoot, 'output'), { recursive: true });
writeFileSync(join(filesRoot, 'output', 'report.txt'), 'hello report');
// logs/(封じ込め外であるべき領域)
mkdirSync(join(filesRoot, 'logs'), { recursive: true });
writeFileSync(join(filesRoot, 'logs', 'secret.txt'), 'should not leak');
// 別アプリ(共有対象外)
mkdirSync(join(filesRoot, 'apps', 'other-app'), { recursive: true });
writeFileSync(join(filesRoot, 'apps', 'other-app', 'index.html'), 'other');
token = repo.createAppShareLink(spaceId, appName, 'user-1').token;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('(a) 有効トークンで GET /api/app-share/:token が 200 で appName を返す', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}`);
expect(res.status).toBe(200);
expect(res.body.app.appName).toBe(appName);
expect(res.body.app.entryPath).toBe(`apps/${appName}/index.html`);
});
it('(b) output/ 配下の read は 200', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/content`).query({ path: 'output/report.txt' });
expect(res.status).toBe(200);
expect(res.text).toBe('hello report');
});
it('(c) apps/{app}/ 配下の read は 200', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/content`).query({ path: `apps/${appName}/data.json` });
expect(res.status).toBe(200);
expect(res.text).toBe('{"k":1}');
});
it('(d) パストラバーサル(../../etc/passwdは 403', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/content`).query({ path: '../../../../etc/passwd' });
expect(res.status).toBe(403);
});
it('(e) logs/ など許可ルート外の read は 403', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/content`).query({ path: 'logs/secret.txt' });
expect(res.status).toBe(403);
});
it('(e2) 他アプリ apps/other-app/ の read は 403共有対象アプリ外', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/content`).query({ path: 'apps/other-app/index.html' });
expect(res.status).toBe(403);
});
it('(f) revoke 後は 404', async () => {
repo.revokeAppShareLink(spaceId, appName);
const app = makeApp(repo, worktreeDir);
const meta = await request(app).get(`/api/app-share/${token}`);
expect(meta.status).toBe(404);
const content = await request(app).get(`/api/app-share/${token}/files/content`).query({ path: 'output/report.txt' });
expect(content.status).toBe(404);
});
it('(g) 不正トークンは 404', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get('/api/app-share/nonexistent-token');
expect(res.status).toBe(404);
});
it('(h) raw レスポンスに Referrer-Policy: no-referrer ヘッダが付く', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/raw`).query({ path: `apps/${appName}/data.json` });
expect(res.status).toBe(200);
expect(res.headers['referrer-policy']).toBe('no-referrer');
});
it('(i) list は許可ルート内のみ。apps/{app} の一覧が取れる', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/list`).query({ dir: `apps/${appName}` });
expect(res.status).toBe(200);
const names = (res.body.entries as Array<{ name: string }>).map((e) => e.name).sort();
expect(names).toEqual(['data.json', 'index.html']);
});
it('(j) list で許可ルート外logs/)は 403', async () => {
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/list`).query({ dir: 'logs' });
expect(res.status).toBe(403);
});
it('(k) symlink 経由の脱出は拒否403', async () => {
// output/ 内から files ルート外の絶対パスへ symlink を張り、それを read しようとする
const filesRoot = spaceFilesDir(worktreeDir, spaceId);
const secretOutside = join(dir, 'outside-secret.txt');
writeFileSync(secretOutside, 'top secret');
try {
symlinkSync(secretOutside, join(filesRoot, 'output', 'link.txt'));
} catch {
return; // symlink 不可な環境ではスキップ
}
const app = makeApp(repo, worktreeDir);
const res = await request(app).get(`/api/app-share/${token}/files/raw`).query({ path: 'output/link.txt' });
expect(res.status).toBe(403);
});
it('(l) write/delete エンドポイントは存在しないPOST は 404', async () => {
const app = makeApp(repo, worktreeDir);
const post = await request(app).post(`/api/app-share/${token}/files/content`).send({ path: 'output/x.txt', content: 'x' });
expect(post.status).toBe(404);
const del = await request(app).delete(`/api/app-share/${token}/files/content`).query({ path: 'output/report.txt' });
expect(del.status).toBe(404);
});
});

171
src/bridge/app-share-api.ts Normal file
View File

@ -0,0 +1,171 @@
import express, { Request, Response } from 'express';
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
import type { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import {
ensurePathWithin,
isPathEscapeError,
serializeLocalFileEntry,
setUntrustedFileResponseHeaders,
} from './local-api-helpers.js';
import { logger } from '../logger.js';
/**
* APIread-only
*
* 1 apps/{appName}/
* read-only `/api/shared/*`share-api.ts
* mount
*
* 封じ込め: token (spaceId, appName) path
* `apps/{appName}/` `output/` 2
* 403/ 404
* token write/delete GET
*/
class ForbiddenPathError extends Error {
constructor() {
super('Path not within an allowed app-share root');
this.name = 'ForbiddenPathError';
}
}
/**
* path apps/{appName}/ output/
* ForbiddenPathErrorensurePathWithin
* /symlink Path escapes workspace
*
* 戦略: 許可ルートごとに ensurePathWithin(allowedRoot, relSuffix)
* relSuffix path ensurePathWithin
* realpath symlink
*/
function resolveWithinAllowedRoots(filesRoot: string, appName: string, requestedPath: string): string {
const rel = requestedPath.replace(/^\/+/, '').replace(/\\/g, '/');
// 許可プレフィックスfilesRoot 相対、末尾スラッシュ付き)
const appPrefix = `apps/${appName}/`;
const outputPrefix = 'output/';
let allowedRoot: string;
let suffix: string;
if (rel === `apps/${appName}` || rel.startsWith(appPrefix)) {
allowedRoot = join(filesRoot, 'apps', appName);
suffix = rel === `apps/${appName}` ? '' : rel.slice(appPrefix.length);
} else if (rel === 'output' || rel.startsWith(outputPrefix)) {
allowedRoot = join(filesRoot, 'output');
suffix = rel === 'output' ? '' : rel.slice(outputPrefix.length);
} else {
throw new ForbiddenPathError();
}
// ensurePathWithin が字句的封じ込め + realpath による symlink 脱出ガードを行う。
// suffix に .. が混ざっていてもここで弾かれるPath escapes workspace
return ensurePathWithin(allowedRoot, suffix);
}
/** 共有アプリのエントリ HTML を探す。apps/{appName}/index.html を優先、無ければ最初の .html。 */
function findEntryPath(filesRoot: string, appName: string): string | null {
const appDir = join(filesRoot, 'apps', appName);
const indexRel = `apps/${appName}/index.html`;
if (existsSync(join(filesRoot, 'apps', appName, 'index.html'))) return indexRel;
let entries: import('node:fs').Dirent[];
try {
entries = readdirSync(appDir, { withFileTypes: true });
} catch {
return null;
}
const html = entries.find((e) => e.isFile() && /\.html?$/i.test(e.name));
return html ? `apps/${appName}/${html.name}` : null;
}
export function mountAppShareApi(app: express.Application, repo: Repository, worktreeDir: string): void {
// token → (spaceId, appName)。失効/不正は null呼び出し側で 404
const resolveToken = (token: string): { spaceId: string; appName: string } | null =>
repo.resolveAppShareToken(token);
// アプリのメタappName / entryPath
app.get('/api/app-share/:token', (req: Request, res: Response) => {
try {
const resolved = resolveToken(req.params.token);
if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
const entryPath = findEntryPath(filesRoot, resolved.appName);
res.json({ app: { appName: resolved.appName, entryPath } });
} catch (err) {
logger.error(`[app-share] meta error: ${err}`);
res.status(500).json({ error: 'Failed to fetch app share' });
}
});
// テキスト読み取り
app.get('/api/app-share/:token/files/content', (req: Request, res: Response) => {
try {
const resolved = resolveToken(req.params.token);
if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
const filePath = resolveWithinAllowedRoots(filesRoot, resolved.appName, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
setUntrustedFileResponseHeaders(res);
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
if (err instanceof ForbiddenPathError) { res.status(403).json({ error: 'Path not allowed' }); return; }
if (isPathEscapeError(err)) { res.status(403).json({ error: 'Path escapes workspace' }); return; }
logger.error(`[app-share] content error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// バイナリ / アセット配信
app.get('/api/app-share/:token/files/raw', (req: Request, res: Response) => {
try {
const resolved = resolveToken(req.params.token);
if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
const filePath = resolveWithinAllowedRoots(filesRoot, resolved.appName, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
// 公開・非信頼配信。CSP sandbox + nosniff + Referer 漏洩低減。
setUntrustedFileResponseHeaders(res);
res.setHeader('Referrer-Policy', 'no-referrer');
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
if (err instanceof ForbiddenPathError) { res.status(403).json({ error: 'Path not allowed' }); return; }
if (isPathEscapeError(err)) { res.status(403).json({ error: 'Path escapes workspace' }); return; }
logger.error(`[app-share] raw error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// ディレクトリ一覧(許可ルート内のみ)
app.get('/api/app-share/:token/files/list', (req: Request, res: Response) => {
try {
const resolved = resolveToken(req.params.token);
if (!resolved) { res.status(404).json({ error: 'Not found' }); return; }
const relativeDir = String(req.query.dir ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!relativeDir) { res.status(400).json({ error: 'dir is required' }); return; }
const filesRoot = spaceFilesDir(worktreeDir, resolved.spaceId);
const dirPath = resolveWithinAllowedRoots(filesRoot, resolved.appName, relativeDir);
const stat = statSync(dirPath);
if (!stat.isDirectory()) { res.status(400).json({ error: 'dir must point to a directory' }); return; }
const entries = readdirSync(dirPath, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.'))
.map((entry) => {
const s = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), s.size, s.mtime);
});
res.json({ basePath: relativeDir.split('/')[0], path: relativeDir, entries });
} catch (err) {
if (err instanceof ForbiddenPathError) { res.status(403).json({ error: 'Path not allowed' }); return; }
if (isPathEscapeError(err)) { res.status(403).json({ error: 'Path escapes workspace' }); return; }
logger.error(`[app-share] list error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
}

View File

@ -0,0 +1,110 @@
/**
* APIS-014 No-auth branding admin passthrough posture.
*
* Branding upload/delete are guarded by an injected `adminGuard` RequestHandler.
* The mount contract (MountBrandingOptions.adminGuard doc) is:
* "Admin-only middleware. When auth is disabled, pass a passthrough."
*
* The server wires this guard differently per deployment:
* - authActive=true adminGuard = requireAdmin (non-admin blocked 401/403)
* - authActive=false adminGuard = passthrough (no admin concept exists)
*
* This file documents + asserts that intended posture at the mount level:
* 1. Passthrough guard (no-auth) upload/delete reach the handler (open).
* 2. Denying guard (auth, non-admin) 403, handler NOT reached.
* 3. Allowing guard (auth, admin) handler reached.
*
* Authorization model (confirmed intended, 2026-06-25): no-auth mode assumes a
* trusted single-tenant deployment with no admin concept, so branding
* upload/delete being open then is by design acceptable, not a hole (same
* passthrough model as admin-api). These tests pin that contract: open under
* passthrough, blocked (403) the moment a real admin guard is wired.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import express, { type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { ConfigManager } from '../config-manager.js';
import { mountBrandingApi } from './branding-api.js';
/** A 1x1 transparent PNG, base64. Small valid favicon payload. */
const PNG_1x1 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
function makeApp(adminGuard: RequestHandler) {
const dir = mkdtempSync(join(tmpdir(), 'maestro-branding-noauth-'));
// Minimal valid config so ConfigManager loads.
const cm = new ConfigManager(join(dir, 'config.yaml'));
const app = express();
mountBrandingApi(app, cm, { brandingDir: join(dir, 'branding'), adminGuard });
return { app, dir };
}
// Passthrough = the no-auth wiring the server uses when authActive=false.
const passthrough: RequestHandler = (_req, _res, next) => next();
// Denying guard = requireAdmin rejecting a non-admin.
const deny403: RequestHandler = (_req, res) => { res.status(403).json({ error: 'forbidden' }); };
describe('APIS-014 branding adminGuard no-auth posture', () => {
let dir = '';
afterEach(() => {
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = ''; }
vi.restoreAllMocks();
});
it('by design: passthrough guard (no-auth, trusted deployment) lets anyone upload branding', async () => {
const a = makeApp(passthrough); dir = a.dir;
const res = await request(a.app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'fav.png', contentBase64: PNG_1x1 });
// Reaches the real handler (no auth) → 200 success, not a 401/403.
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('by design: passthrough guard (no-auth, trusted deployment) lets anyone delete branding', async () => {
const a = makeApp(passthrough); dir = a.dir;
const res = await request(a.app)
.delete('/api/branding/upload')
.send({ kind: 'favicon' });
// Reaches the handler; not blocked by auth. (200 or 400 depending on body
// validation, but crucially NOT 401/403.)
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it('denying guard (auth, non-admin) → 403 and the upload handler is NOT reached', async () => {
const a = makeApp(deny403); dir = a.dir;
const res = await request(a.app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'fav.png', contentBase64: PNG_1x1 });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'forbidden' });
});
it('denying guard (auth, non-admin) → 403 on delete too', async () => {
const a = makeApp(deny403); dir = a.dir;
const res = await request(a.app).delete('/api/branding/upload').send({ kind: 'favicon' });
expect(res.status).toBe(403);
});
it('allowing guard (auth, admin) reaches the upload handler', async () => {
// Admin guard that calls next() — equivalent to requireAdmin passing.
const allow: RequestHandler = (_req, _res, next) => next();
const a = makeApp(allow); dir = a.dir;
const res = await request(a.app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'fav.png', contentBase64: PNG_1x1 });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('public GET /api/branding stays open regardless of guard (login page needs it)', async () => {
const a = makeApp(deny403); dir = a.dir;
const res = await request(a.app).get('/api/branding');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('appName');
});
});

View File

@ -0,0 +1,113 @@
/**
* APIS-062 Dashboard API auth guard.
*
* `createDashboardApi` exposes worker idle/running status and node (GPU)
* status. The inventory flagged "no explicit auth guard visible". On reading
* the source, a router-level guard DOES exist:
*
* r.use((req,res,next) => {
* if (!authActive && !getUser(req)) req.user = { id:'local', role:'user' };
* if (!getUser(req)) { res.status(401).json({error:'Unauthenticated'}); return; }
* next();
* });
*
* So:
* - authActive=true + NO req.user 401 (authentication IS enforced)
* - authActive=true + any authenticated user 200
* - authActive=false + NO req.user 200 (synthetic 'local' user)
*
* The existing dashboard-api.test.ts always injects a user and asserts 0 authz
* cases; this file asserts the guard end-to-end (the unauthenticated path).
*
* Authorization model (confirmed intended, 2026-06-25): the guard is
* authentication-only by design. Worker / GPU-node topology is information any
* logged-in user may see, so there is intentionally NO requireAdmin gate. The
* test below pins that contract: an authenticated non-admin user gets 200.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { createDashboardApi } from './dashboard-api.js';
function makeApp(opts: {
user?: { id: string; role: 'admin' | 'user' } | null;
authActive?: boolean;
repo: Repository;
}): express.Application {
const app = express();
app.use(express.json());
// Only inject a user when one is supplied; null/undefined => unauthenticated.
if (opts.user) {
app.use((req, _res, next) => {
(req as any).user = opts.user;
next();
});
}
app.use(
'/api/local/dashboard',
createDashboardApi({
repo: opts.repo,
getWorkers: () => [{ id: 'w1', endpoint: 'http://x/v1', roles: ['task'] }],
authActive: opts.authActive ?? true,
backendStatusRegistry: null,
}),
);
return app;
}
describe('APIS-062 dashboard-api auth guard', () => {
let tmpDir = '';
let repo: Repository;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'maestro-dashboard-guard-'));
repo = new Repository(join(tmpDir, 'test.db'));
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it('authActive=true + unauthenticated → 401 on /workers (guard enforced)', async () => {
const app = makeApp({ user: null, authActive: true, repo });
const res = await request(app).get('/api/local/dashboard/workers');
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Unauthenticated' });
});
it('authActive=true + authenticated user → 200 on /workers', async () => {
const app = makeApp({ user: { id: 'u1', role: 'user' }, authActive: true, repo });
const res = await request(app).get('/api/local/dashboard/workers');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.workers)).toBe(true);
});
it('authActive=false + unauthenticated → 200 (synthetic local user, by design)', async () => {
const app = makeApp({ user: null, authActive: false, repo });
const res = await request(app).get('/api/local/dashboard/workers');
expect(res.status).toBe(200);
});
it('node-status route is also behind the auth guard (401 when unauthenticated)', async () => {
const app = makeApp({ user: null, authActive: true, repo });
const res = await request(app).get('/api/local/dashboard/node-status');
// Guard fires before the route, so 401 (not the 503 "registry not configured").
expect(res.status).toBe(401);
});
// By design: worker/GPU-node topology is visible to ALL authenticated users
// (not admin-only). Confirmed intended 2026-06-25 — there is deliberately no
// requireAdmin gate; this pins the contract so a future accidental tightening
// (or loosening to unauthenticated) is caught.
it('authActive=true + non-admin authenticated user → 200 on /workers (topology is all-users, by design)', async () => {
const app = makeApp({ user: { id: 'plain', role: 'user' }, authActive: true, repo });
const res = await request(app).get('/api/local/dashboard/workers');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.workers)).toBe(true);
});
});

View File

@ -0,0 +1,321 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createDelegateRunsRouter } from './delegate-runs-api.js';
import type { Repository } from '../db/repository.js';
// ---------------------------------------------------------------------------
// Minimal repo mock — mirrors subtask-activity-api.test.ts pattern
// ---------------------------------------------------------------------------
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
getLocalTask: vi.fn(),
getLatestJobForIssue: vi.fn(),
getSubJobs: vi.fn(),
getJob: vi.fn(),
userCanViewSpace: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Repository;
}
// ---------------------------------------------------------------------------
// Seed helpers
// ---------------------------------------------------------------------------
/** Write lines to {workspacePath}/logs/events.jsonl (logRoot fallback path). */
function writeEventsJsonl(workspacePath: string, lines: object[]) {
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
writeFileSync(
join(workspacePath, 'logs', 'events.jsonl'),
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
'utf-8',
);
}
// Canonical minimal event factories
const START = (id: string) => ({
v: 1,
ts: '2026-06-25T00:00:01.000Z',
seq: 1,
eventId: 's',
runId: 'r',
kind: 'delegate_start',
payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1 },
});
const TOOL = (id: string) => ({
v: 1,
ts: '2026-06-25T00:00:02.000Z',
seq: 2,
eventId: 't',
runId: 'r',
kind: 'tool_call',
correlationId: id,
payload: { toolName: 'Read', toolCallId: 'tc1' },
});
const DONE = (id: string) => ({
v: 1,
ts: '2026-06-25T00:00:03.000Z',
seq: 3,
eventId: 'c',
runId: 'r',
kind: 'delegate_complete',
payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1, next: 'COMPLETE', status: 'success' },
});
// ---------------------------------------------------------------------------
// seedTaskWithWorkspace: build a test Express app + mocked repo + a task that
// has a non-null workspacePath backed by a real tmp directory.
// Also provides otherUsersTaskId whose getLocalTask mock returns null (viewer
// cannot see it), to test the visibility gate.
// ---------------------------------------------------------------------------
async function seedTaskWithWorkspace() {
const workspacePath = mkdtempSync(join(tmpdir(), 'delegate-runs-test-'));
const TASK = {
id: 1,
title: 'test task',
workspacePath,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
};
const repo = makeRepo();
// By default task 1 is visible (no user set on req, so canViewTask returns true)
vi.mocked(repo.getLocalTask).mockImplementation(async (id: number) => {
if (id === 1) return TASK as never;
return null as never;
});
const app = express();
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
return { app, repo, taskId: 1, workspacePath, otherUsersTaskId: 99 };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GET /api/local/tasks/:id/delegate-runs', () => {
let tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs = [];
vi.clearAllMocks();
});
it('events.jsonl から run 一覧を返す', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toHaveLength(1);
expect(res.body.runs[0]).toMatchObject({ delegateRunId: 'R1', status: 'success', toolCalls: 1 });
});
it('events.jsonl 不在なら空配列(エラーにしない)', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
// No events.jsonl written
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toEqual([]);
});
it('複数 run を delegateRunId で区別して返す', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeEventsJsonl(workspacePath, [
START('R1'), TOOL('R1'), DONE('R1'),
START('R2'), DONE('R2'),
]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
expect(res.status).toBe(200);
expect(res.body.runs).toHaveLength(2);
expect(res.body.runs.map((r: { delegateRunId: string }) => r.delegateRunId)).toEqual(['R1', 'R2']);
});
it('非可視タスクは 404 を返す', async () => {
const { app, otherUsersTaskId } = await seedTaskWithWorkspace();
const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs`);
expect([403, 404]).toContain(res.status);
});
it('authenticated non-owner viewer blocked from private task (IDOR protection)', async () => {
const { repo, taskId, workspacePath } = await seedTaskWithWorkspace();
const tmpDirs = [workspacePath];
const mockGetLocalTask = vi.mocked(repo.getLocalTask);
// Configure mock: when viewer is passed (authenticated request),
// return null (simulating DB-level visibility filter)
mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => {
if (id === taskId) {
if (opts?.viewer) {
// Non-owner viewer: visibility filter returns null
return null as never;
}
// No viewer (unauthenticated): allowed to view
return {
id: taskId,
title: 'test task',
workspacePath,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
} as never;
}
return null as never;
});
// Create app with middleware that injects non-owner viewer
const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User;
const app = express();
app.use((req: any, _res: any, next: any) => {
req.user = nonOwnerUser;
next();
});
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
// Verify: request is blocked (404 or 403) and no data is leaked
expect([403, 404]).toContain(res.status);
expect(res.body.runs).toBeUndefined();
expect(res.body.error).toBeDefined();
// Verify: getLocalTask was called WITH the viewer object
expect(mockGetLocalTask).toHaveBeenCalledWith(
taskId,
expect.objectContaining({ viewer: nonOwnerUser })
);
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});
describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () => {
let tmpDirs: string[] = [];
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs = [];
vi.clearAllMocks();
});
it('drill-down: その run の内部イベントのみ返す', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
expect(res.status).toBe(200);
// Events that have correlationId === 'R1' — TOOL has it; START/DONE do not since they
// are keyed by kind not correlationId in the filter
expect(Array.isArray(res.body.events)).toBe(true);
// The TOOL event has correlationId === 'R1', others do not
expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true);
});
it('timeline: events.jsonl 不在なら空配列', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath);
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
expect(res.status).toBe(200);
expect(res.body.events).toEqual([]);
});
it('timeline: 非可視タスクは 404', async () => {
const { app, otherUsersTaskId } = await seedTaskWithWorkspace();
const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs/R1/timeline`);
expect([403, 404]).toContain(res.status);
});
it('timeline: authenticated non-owner viewer blocked from private task (IDOR protection)', async () => {
const { repo, taskId, workspacePath } = await seedTaskWithWorkspace();
const tmpDirs = [workspacePath];
const mockGetLocalTask = vi.mocked(repo.getLocalTask);
// Configure mock: when viewer is passed (authenticated request),
// return null (simulating DB-level visibility filter)
mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => {
if (id === taskId) {
if (opts?.viewer) {
// Non-owner viewer: visibility filter returns null
return null as never;
}
// No viewer (unauthenticated): allowed to view
return {
id: taskId,
title: 'test task',
workspacePath,
runtimeDir: null,
ownerId: 'alice-id',
visibility: 'private' as const,
visibilityScopeOrgId: null,
spaceId: null,
} as never;
}
return null as never;
});
// Create app with middleware that injects non-owner viewer
const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User;
const app = express();
app.use((req: any, _res: any, next: any) => {
req.user = nonOwnerUser;
next();
});
app.use(express.json());
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
// Verify: request is blocked (404 or 403) and no data is leaked
expect([403, 404]).toContain(res.status);
expect(res.body.events).toBeUndefined();
expect(res.body.error).toBeDefined();
// Verify: getLocalTask was called WITH the viewer object
expect(mockGetLocalTask).toHaveBeenCalledWith(
taskId,
expect.objectContaining({ viewer: nonOwnerUser })
);
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
});

View File

@ -0,0 +1,66 @@
import { Router, type Request, type Response } from 'express';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { type Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { logRoot } from '../spaces/runtime-paths.js';
import { parseEventLine, type EventBase } from '../progress/event-log.js';
import { reconstructDelegateRuns } from '../progress/delegate-runs.js';
import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
/** 巨大 events.jsonl の安全弁: 末尾 N バイトだけ読む(先頭行が途中で切れたら捨てる)。 */
const MAX_EVENTS_BYTES = 32 * 1024 * 1024;
function readEvents(task: { workspacePath: string | null; runtimeDir?: string | null }): EventBase[] {
if (!task.workspacePath) return [];
const path = join(logRoot({ workspacePath: task.workspacePath, runtimeDir: task.runtimeDir }), 'events.jsonl');
if (!existsSync(path)) return [];
let raw: string;
try {
raw = readFileSync(path, 'utf-8');
} catch {
return [];
}
if (raw.length > MAX_EVENTS_BYTES) raw = raw.slice(raw.length - MAX_EVENTS_BYTES);
const out: EventBase[] = [];
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
const r = parseEventLine(line);
if (r.kind === 'ok') out.push(r.event);
}
return out;
}
export function createDelegateRunsRouter(repo: Repository): Router {
const router = Router();
router.get('/:id/delegate-runs', async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ runs: reconstructDelegateRuns(readEvents(task!)) });
} catch (err) {
logger.error(`[delegate-runs] list error: ${err}`);
res.status(500).json({ error: 'Failed to fetch delegate runs' });
}
});
router.get('/:id/delegate-runs/:delegateRunId/timeline', async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id);
const runId = req.params.delegateRunId;
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
const events = readEvents(task!).filter((e) => e.correlationId === runId);
res.json({ events });
} catch (err) {
logger.error(`[delegate-runs] timeline error: ${err}`);
res.status(500).json({ error: 'Failed to fetch delegate run timeline' });
}
});
return router;
}

View File

@ -0,0 +1,140 @@
/**
* APIS-052 Gateway proxy auth boundary.
*
* The gateway proxies LLM traffic. gateway-mount.test.ts covers path
* classification + config equivalence, but NOT the authentication of proxied
* requests. An unauthenticated request slipping past the gateway's auth layer
* would expose backend LLM spend.
*
* This file asserts the boundary at the gateway's real auth middleware
* (`buildAuthMiddleware` from src/gateway/auth.ts the exact handler the
* gateway sub-app installs ahead of the proxy). We mount it on a bare express
* app in front of a SENTINEL "backend" handler that records whether it was
* reached. No real LLM call is made.
*
* - missing Bearer 401, backend NOT reached (no passthrough)
* - malformed Authorization 401, backend NOT reached
* - wrong/unknown key 401, backend NOT reached
* - valid config key 200, backend reached, gatewayAuth populated
* - valid DB key (dbLookup) 200, backend reached
* - 401 body does not leak which step failed
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createHash } from 'node:crypto';
import { buildAuthMiddleware } from '../gateway/auth.js';
import type { GatewayVirtualKey } from '../gateway/config.js';
const VALID_CONFIG_KEY = 'sk-aao-valid-config-key';
const VALID_DB_KEY = 'sk-aao-valid-db-key';
/** Same hashing the middleware uses for dbLookup (sha256 hex of the bearer). */
function sha256hex(s: string): string {
return createHash('sha256').update(s).digest('hex');
}
/**
* Bare app: [json] [gateway auth middleware] [sentinel backend].
* The sentinel is the stand-in for the proxied LLM backend; if auth lets a
* request through, `backend.mock.calls.length` becomes > 0.
*/
function makeGatewayApp(opts: {
keys?: GatewayVirtualKey[];
dbLookup?: (keyHash: string) => { id: string; team: string; allowedModels?: string[] | null } | null;
}) {
const backend = vi.fn((_req: express.Request, res: express.Response) => {
res.status(200).json({ ok: true, served: 'fake-backend' });
});
const app = express();
app.use(express.json());
app.use(buildAuthMiddleware({ keys: opts.keys ?? [], dbLookup: opts.dbLookup }));
// Stand-in for the proxied LLM endpoint.
app.post('/v1/chat/completions', backend);
return { app, backend };
}
const configKeys: GatewayVirtualKey[] = [
{ key: VALID_CONFIG_KEY, team: 'team-a' },
];
describe('APIS-052 gateway proxy auth boundary', () => {
afterEach(() => vi.restoreAllMocks());
it('missing Authorization header → 401 and the backend is never reached', async () => {
const { app, backend } = makeGatewayApp({ keys: configKeys });
const res = await request(app)
.post('/v1/chat/completions')
.send({ model: 'auto', messages: [] });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid api key' });
expect(backend).not.toHaveBeenCalled();
});
it('malformed Authorization (non-Bearer scheme) → 401, no passthrough', async () => {
const { app, backend } = makeGatewayApp({ keys: configKeys });
const res = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Basic ${VALID_CONFIG_KEY}`)
.send({ model: 'auto' });
expect(res.status).toBe(401);
expect(backend).not.toHaveBeenCalled();
});
it('wrong/unknown Bearer key → 401, no passthrough', async () => {
const { app, backend } = makeGatewayApp({ keys: configKeys });
const res = await request(app)
.post('/v1/chat/completions')
.set('Authorization', 'Bearer sk-aao-totally-wrong')
.send({ model: 'auto' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid api key' });
expect(backend).not.toHaveBeenCalled();
});
it('valid config key → 200, backend reached', async () => {
const { app, backend } = makeGatewayApp({ keys: configKeys });
const res = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${VALID_CONFIG_KEY}`)
.send({ model: 'auto' });
expect(res.status).toBe(200);
expect(res.body.served).toBe('fake-backend');
expect(backend).toHaveBeenCalledTimes(1);
});
it('valid DB key (via dbLookup) → 200, backend reached', async () => {
const dbLookup = (hash: string) =>
hash === sha256hex(VALID_DB_KEY) ? { id: 'k1', team: 'team-db', allowedModels: null } : null;
const { app, backend } = makeGatewayApp({ keys: [], dbLookup });
const res = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${VALID_DB_KEY}`)
.send({ model: 'auto' });
expect(res.status).toBe(200);
expect(backend).toHaveBeenCalledTimes(1);
});
it('a key revoked from the DB (dbLookup miss) and absent from config → 401', async () => {
// Simulate post-revocation: dbLookup returns null for everything, no config keys.
const dbLookup = () => null;
const { app, backend } = makeGatewayApp({ keys: [], dbLookup });
const res = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${VALID_DB_KEY}`)
.send({ model: 'auto' });
expect(res.status).toBe(401);
expect(backend).not.toHaveBeenCalled();
});
it('401 body is identical for missing vs wrong key (no information leak)', async () => {
const { app } = makeGatewayApp({ keys: configKeys });
const missing = await request(app).post('/v1/chat/completions').send({});
const wrong = await request(app)
.post('/v1/chat/completions')
.set('Authorization', 'Bearer sk-aao-nope')
.send({});
expect(missing.body).toEqual(wrong.body);
expect(missing.status).toBe(wrong.status);
});
});

View File

@ -1,7 +1,8 @@
import { EventEmitter } from 'events';
export interface JobStreamEvent {
type: 'prompt_progress' | 'text' | 'tool_use' | 'tool_use_delta' | 'tool_result' | 'done';
type: 'prompt_progress' | 'text' | 'tool_use' | 'tool_use_delta' | 'tool_result' | 'done'
| 'delegate_lifecycle' | 'delegate_text' | 'delegate_tool';
// prompt_progress
processed?: number;
total?: number;
@ -18,6 +19,12 @@ export interface JobStreamEvent {
// tool_use_delta (live tool-call argument streaming)
name?: string;
chunk?: string;
// delegate live console
delegateRunId?: string;
parentRunId?: string | null;
depth?: number;
description?: string;
status?: 'running' | 'success' | 'aborted' | 'needs_user_input';
}
class JobEventBus extends EventEmitter {

View File

@ -3,7 +3,24 @@ import type { Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import { join, posix, win32 } from 'path';
import { setUntrustedFileResponseHeaders, ensurePathWithin, isPathEscapeError, isNotFoundError, safeZipEntryName } from './local-api-helpers.js';
import { setUntrustedFileResponseHeaders, ensurePathWithin, isPathEscapeError, isNotFoundError, safeZipEntryName, isJobWithinWorkspace, isProtectedWorkspaceDir, collectZipFiles } from './local-api-helpers.js';
describe('isJobWithinWorkspace (separator-bounded containment)', () => {
it('accepts the workspace itself and descendants', () => {
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/12')).toBe(true);
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/12/subtasks/1')).toBe(true);
});
it('rejects a sibling sharing only a string prefix (the /12 vs /123 越境)', () => {
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/123')).toBe(false);
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/123/subtasks/1')).toBe(false);
expect(isJobWithinWorkspace('/wt/ws', '/wt/ws-evil/output')).toBe(false);
});
it('rejects null/empty inputs', () => {
expect(isJobWithinWorkspace(null, '/wt/local/12')).toBe(false);
expect(isJobWithinWorkspace('/wt/local/12', null)).toBe(false);
expect(isJobWithinWorkspace('', '/wt/local/12')).toBe(false);
});
});
describe('safeZipEntryName', () => {
const root = '/srv/ws';
@ -124,3 +141,59 @@ describe('ensurePathWithin (symlink escape hardening)', () => {
expect(isPathEscapeError(err)).toBe(true);
});
});
describe('isProtectedWorkspaceDir (構造フォルダの削除ガード)', () => {
let wsRoot: string;
beforeEach(() => { wsRoot = fs.mkdtempSync(join(os.tmpdir(), 'protect-dir-')); });
afterEach(() => { fs.rmSync(wsRoot, { recursive: true, force: true }); });
it('protects every top-level structure dir', () => {
for (const d of ['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills', 'subtasks']) {
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, d))).toBe(true);
}
});
it('does not protect nested dirs or user-created top-level dirs', () => {
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, 'output', 'sub'))).toBe(false);
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, 'myfolder'))).toBe(false);
});
it('does not protect the workspace root itself, and rejects escapes', () => {
expect(isProtectedWorkspaceDir(wsRoot, wsRoot)).toBe(false);
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, '..', 'output'))).toBe(false);
});
});
describe('collectZipFiles (フォルダの再帰 zip 収集)', () => {
let zroot: string;
beforeEach(() => { zroot = fs.mkdtempSync(join(os.tmpdir(), 'collect-zip-')); });
afterEach(() => { fs.rmSync(zroot, { recursive: true, force: true }); });
it('returns a single entry for a file (name relative to root)', () => {
fs.writeFileSync(join(zroot, 'a.txt'), 'x');
expect(collectZipFiles(zroot, join(zroot, 'a.txt')).map(g => g.entryName)).toEqual(['a.txt']);
});
it('recurses into a directory and keeps the folder structure in entry names', () => {
fs.mkdirSync(join(zroot, 'd', 'e'), { recursive: true });
fs.writeFileSync(join(zroot, 'd', 'top.txt'), '1');
fs.writeFileSync(join(zroot, 'd', 'e', 'deep.txt'), '2');
const got = collectZipFiles(zroot, join(zroot, 'd')).map(g => g.entryName).sort();
expect(got).toEqual(['d/e/deep.txt', 'd/top.txt']);
});
it('skips symlinks so a link cannot pull in content from outside', () => {
fs.mkdirSync(join(zroot, 'd'), { recursive: true });
fs.writeFileSync(join(zroot, 'secret.txt'), 's');
try {
fs.symlinkSync(join(zroot, 'secret.txt'), join(zroot, 'd', 'link.txt'));
} catch {
return; // symlink 不可な環境ではスキップ
}
expect(collectZipFiles(zroot, join(zroot, 'd')).map(g => g.entryName)).toEqual([]);
});
it('returns [] for a missing path', () => {
expect(collectZipFiles(zroot, join(zroot, 'nope'))).toEqual([]);
});
});

View File

@ -22,6 +22,57 @@ export function safeZipEntryName(rootDir: string, abs: string): string {
.join('/');
}
/**
* top-level
* UI workspaceDirRole / scaffold
*/
export const RESERVED_WORKSPACE_DIRS = new Set([
'input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills', 'subtasks',
]);
/**
* `abs` input/output/... true
* output/sub false
* delete 使UI API
*
*/
export function isProtectedWorkspaceDir(workspaceRoot: string, abs: string): boolean {
const rel = relative(workspaceRoot, abs);
if (!rel || rel.startsWith('..') || rel.includes(sep)) return false;
return RESERVED_WORKSPACE_DIRS.has(rel);
}
/**
* `absTarget` or ensurePathWithin rootDir
* zip entry rootDir
* safeZipEntryName zip-slip symlink
* 64
*/
export function collectZipFiles(
rootDir: string,
absTarget: string,
depth = 0,
): { abs: string; entryName: string }[] {
if (depth > 64) return [];
let lst;
try { lst = fs.lstatSync(absTarget); } catch { return []; }
if (lst.isSymbolicLink()) return []; // symlink は展開しない(封じ込め脱出を防ぐ)
if (lst.isFile()) {
const entryName = safeZipEntryName(rootDir, absTarget);
return entryName ? [{ abs: absTarget, entryName }] : [];
}
if (lst.isDirectory()) {
const out: { abs: string; entryName: string }[] = [];
let entries: string[];
try { entries = fs.readdirSync(absTarget); } catch { return []; }
for (const e of entries) {
out.push(...collectZipFiles(rootDir, join(absTarget, e), depth + 1));
}
return out;
}
return [];
}
export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string {
const base = worktreeDir ?? '/tmp/maestro/workspaces';
return join(base, 'local', String(taskId));
@ -73,6 +124,24 @@ export function isPathEscapeError(err: unknown): boolean {
return err instanceof Error && err.message === 'Path escapes workspace';
}
/**
* worktree workspace
*
* `worktreePath.startsWith(workspacePath)` `.../local/12` `.../local/123`
* id=123 token
* `resolve` prefix
* (`/api/shared/.../subtasks/...`) subtask API
*/
export function isJobWithinWorkspace(
workspacePath: string | null | undefined,
worktreePath: string | null | undefined,
): boolean {
if (!workspacePath || !worktreePath) return false;
const base = resolve(workspacePath);
const wt = resolve(worktreePath);
return wt === base || wt.startsWith(base + sep);
}
/**
* A filesystem "this path does not exist" error: a missing file (ENOENT) or a
* non-directory in the path (ENOTDIR). Callers map these to 404, not 500 a
@ -95,12 +164,6 @@ export function serializeLocalFileEntry(relativePath: string, name: string, isDi
};
}
export function getOwnerFilter(req: Request): { ownerId?: string } {
if (!req.user) return {};
if (req.user.role === 'admin') return {};
return { ownerId: req.user.id };
}
export function checkTaskOwnership(req: Request, res: Response, task: { ownerId?: string | null } | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
if (req.user && req.user.role !== 'admin' && task.ownerId !== req.user?.id) {

View File

@ -0,0 +1,186 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import ExcelJS from 'exceljs';
import { mountLocalFilesApi } from './local-files-api.js';
import type { Repository } from '../db/repository.js';
// Functional tests driving two PARTIAL gaps end-to-end through the HTTP layer:
// GET /files/office-preview (APIC-020): section-enum 400, canViewTask gate,
// SofficeUnavailableError → 503 mapping, happy-path xlsx.
// GET /files/content + /files/raw traversal guard (APIC-018/019, gap #4):
// an actual path-escape payload is rejected at the route.
//
// Mirrors src/bridge/local-files-api.test.ts: a vi.fn()-backed fake Repository
// + a bare express() app + the real mountLocalFilesApi.
let ws: string;
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
ownerId: 'user-1',
visibility: 'private',
workspacePath: ws,
}),
getLatestJobForIssue: vi.fn().mockResolvedValue(null),
userCanViewSpace: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Repository;
}
function makeUser(overrides: Partial<Express.User> = {}): Express.User {
return {
id: 'user-1',
email: 'u@example.com',
name: 'User One',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
...overrides,
};
}
function makeApp(repo: Repository, user?: Express.User): express.Application {
const app = express();
if (user) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
}
mountLocalFilesApi(app, repo, { authActive: true });
return app;
}
beforeEach(async () => {
ws = mkdtempSync(join(tmpdir(), 'local-files-office-'));
mkdirSync(join(ws, 'input'), { recursive: true });
// A real .xlsx so the happy path renders without soffice.
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet('Data');
sheet.addRow(['name', 'age']);
sheet.addRow(['Alice', 30]);
await wb.xlsx.writeFile(join(ws, 'input', 'book.xlsx'));
// A .pptx whose conversion would invoke soffice (used for the 503 test).
writeFileSync(join(ws, 'input', 'deck.pptx'), 'dummy');
// A secret just outside the workspace that traversal must never reach.
writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'top-secret');
});
afterEach(() => {
rmSync(ws, { recursive: true, force: true });
rmSync(join(ws, '..', `outside-${process.pid}.txt`), { force: true });
});
describe('GET /api/local/tasks/:taskId/files/office-preview', () => {
it('renders an xlsx into a spreadsheet preview JSON (happy path)', async () => {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app)
.get('/api/local/tasks/1/files/office-preview')
.query({ section: 'input', path: 'book.xlsx' });
expect(res.status).toBe(200);
expect(res.body.kind).toBe('spreadsheet');
expect(res.body.sheets[0].name).toBe('Data');
expect(res.body.sheets[0].rows[0]).toEqual(['name', 'age']);
});
it('rejects an invalid section with 400', async () => {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app)
.get('/api/local/tasks/1/files/office-preview')
.query({ section: 'etc', path: 'book.xlsx' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('section must be');
});
it('denies a non-viewer of a private task (404, no leak)', async () => {
const app = makeApp(makeRepo(), makeUser({ id: 'intruder' }));
const res = await request(app)
.get('/api/local/tasks/1/files/office-preview')
.query({ section: 'input', path: 'book.xlsx' });
// canViewTask → 404 for a non-owner of a private task.
expect(res.status).toBe(404);
expect(res.body).not.toHaveProperty('sheets');
});
it('maps SofficeUnavailableError to 503 when the converter is missing', async () => {
// The pptx path invokes soffice; point SOFFICE_BIN at a nonexistent binary
// so the spawn fails with ENOENT → SofficeUnavailableError regardless of
// whether soffice is actually installed in the environment.
const prev = process.env.SOFFICE_BIN;
process.env.SOFFICE_BIN = join(ws, 'definitely-not-soffice');
try {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app)
.get('/api/local/tasks/1/files/office-preview')
.query({ section: 'input', path: 'deck.pptx' });
expect(res.status).toBe(503);
expect(res.body.error).toBe('converter_unavailable');
} finally {
if (prev === undefined) delete process.env.SOFFICE_BIN;
else process.env.SOFFICE_BIN = prev;
}
});
it('rejects a traversal payload on office-preview (path escapes workspace)', async () => {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app)
.get('/api/local/tasks/1/files/office-preview')
.query({ section: 'input', path: `../outside-${process.pid}.txt` });
// ensurePathWithin throws → handleOfficePreviewError maps escape → 400.
expect(res.status).toBe(400);
expect(res.text).not.toContain('top-secret');
});
});
describe('/files/* traversal guard (drives the route, not just the helper)', () => {
// GAP: the task brief expects 403 for /files/* traversal, but the
// /files/content and /files/raw routes return 400 ("Path escapes workspace")
// via isPathEscapeError — NOT 403. (403 is the subtask-files-api.ts contract,
// APIC-045, a different route.) This is a status-code naming discrepancy in
// the spec, not a bug: traversal IS blocked here; the secret never leaks. We
// assert the actual contract (400) and that no bytes escape.
it('blocks ../ traversal on /files/content with 400', async () => {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app)
.get('/api/local/tasks/1/files/content')
.query({ section: 'input', path: `../outside-${process.pid}.txt` });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
expect(res.text).not.toContain('top-secret');
});
it('blocks an encoded ..%2f traversal on /files/content with 400', async () => {
const app = makeApp(makeRepo(), makeUser());
// Send a pre-decoded payload — supertest passes the query through; the
// escape segment still resolves outside the root.
const res = await request(app)
.get('/api/local/tasks/1/files/content')
.query({ section: 'input', path: `../../etc/passwd` });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
expect(res.text).not.toContain('root:');
});
it('blocks ../ traversal on /files/raw with 400', async () => {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app)
.get('/api/local/tasks/1/files/raw')
.query({ section: 'input', path: `../outside-${process.pid}.txt` });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
expect(res.text).not.toContain('top-secret');
});
});

View File

@ -491,11 +491,12 @@ describe('POST /api/local/tasks/:taskId/files/delete', () => {
expect(res.body.skipped).toEqual(['nope.txt']);
});
it('skips directories', async () => {
it('deletes a directory recursively (with its contents)', async () => {
expect(existsSync(join(ws, 'output', 'sub', 'nested.txt'))).toBe(true);
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['sub'] });
expect(res.status).toBe(200);
expect(res.body.skipped).toEqual(['sub']);
expect(existsSync(join(ws, 'output', 'sub'))).toBe(true);
expect(res.body.deleted).toEqual(['sub']);
expect(existsSync(join(ws, 'output', 'sub'))).toBe(false);
});
it('hides the task from a non-owner with 404', async () => {
@ -588,8 +589,15 @@ describe('POST /api/local/tasks/:taskId/files/download-zip', () => {
expect(res.status).toBe(404);
});
it('returns 404 when nothing matches (missing/dir paths skipped)', async () => {
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['nope.md', 'sub'] });
it('zips a directory recursively (nested files keep their folder path)', async () => {
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['sub'] });
expect(res.status).toBe(200);
const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName).sort();
expect(names).toEqual(['sub/nested.txt']);
});
it('returns 404 when nothing matches (only missing paths)', async () => {
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['nope.md'] });
expect(res.status).toBe(404);
});

View File

@ -1,12 +1,13 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, openSync, writeSync, closeSync, unlinkSync } from 'fs';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, openSync, writeSync, closeSync, unlinkSync, rmSync } from 'fs';
import { join, extname, basename, dirname } from 'path';
import AdmZip from 'adm-zip';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, isPathEscapeError, isNotFoundError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders, safeZipEntryName } from './local-api-helpers.js';
import { ensurePathWithin, isPathEscapeError, isNotFoundError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders, isProtectedWorkspaceDir, collectZipFiles } from './local-api-helpers.js';
import { logRoot } from '../spaces/runtime-paths.js';
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';
/**
* root 5
@ -197,6 +198,45 @@ export function mountLocalFilesApi(
}
});
// Excel / PowerPoint をプレビュー用に変換して返す。閲覧操作なので canViewTask ゲート。
// Excel→シートのセル配列(JSON)、PPTX→スライド画像(PNG data URL)。soffice 未導入なら 503。
app.get('/api/local/tasks/:taskId/files/office-preview', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = sectionRoot(task, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
await sendOfficePreview(res, filePath, basename(filePath));
} catch (err) {
handleOfficePreviewError(res, err);
}
});
app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
@ -376,6 +416,7 @@ export function mountLocalFilesApi(
const abs = ensurePathWithin(rootDir, rel); // escape は throw → 下で 400 化(削除前)
resolved.push({ rel, abs });
}
const workspaceRoot = sectionRoot(task, 'workspace');
for (const { rel, abs } of resolved) {
let stat;
try {
@ -384,9 +425,18 @@ export function mountLocalFilesApi(
skipped.push(rel); // 存在しない → 冪等にスキップ
continue;
}
if (!stat.isFile()) { skipped.push(rel); continue; } // ディレクトリ等は対象外
unlinkSync(abs);
deleted.push(rel);
if (stat.isFile()) {
unlinkSync(abs);
deleted.push(rel);
} else if (stat.isDirectory()) {
// 構造フォルダinput/output/logs/apps/readonly/source/skills/subtasksは足場なので
// 削除禁止。UI ガードだけだと API 直叩きで消せるためサーバ側でも拒否(二層防御)。
if (isProtectedWorkspaceDir(workspaceRoot, abs)) { skipped.push(rel); continue; }
rmSync(abs, { recursive: true, force: true }); // フォルダごと(中身含む)削除
deleted.push(rel);
} else {
skipped.push(rel); // 特殊ファイル等は対象外
}
}
res.json({ deleted, skipped });
@ -438,14 +488,12 @@ export function mountLocalFilesApi(
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) continue;
const abs = ensurePathWithin(rootDir, rel); // escape は throw → 下で 400 化
let stat;
try { stat = statSync(abs); } catch { continue; } // 不在はスキップ
if (!stat.isFile()) continue; // ディレクトリ等は対象外
// zip エントリ名は封じ込め済み abs から安全に再生成zip-slip 対策、共有ヘルパ)。
const entryName = safeZipEntryName(rootDir, abs);
if (!entryName) continue;
zip.addFile(entryName, readFileSync(abs));
added++;
// ファイルはそのまま、ディレクトリは配下を再帰収集entry 名は rootDir 相対で
// フォルダ構造を保持。zip-slip 対策・symlink 除外は collectZipFiles 内で実施)。
for (const { abs: fileAbs, entryName } of collectZipFiles(rootDir, abs)) {
zip.addFile(entryName, readFileSync(fileAbs));
added++;
}
}
if (added === 0) {
res.status(404).json({ error: 'no downloadable files' });

View File

@ -0,0 +1,200 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
// Functional tests for two previously-untested control routes:
// PUT /api/local/tasks/:taskId/mission (APIC-007, gap: NONE)
// POST /api/local/tasks/:taskId/regenerate-title (APIC-011, gap: PARTIAL)
// Uses a real temp-file Repository + the real mountLocalTasksApi so persistence
// is exercised end-to-end (mission brief round-trips through the DB).
let tempDir = '';
let repo: Repository;
function makeUser(overrides: Partial<Express.User> = {}): Express.User {
return {
id: 'owner-1',
email: 'owner@x.com',
name: 'Owner',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
...overrides,
};
}
function makeApp(
user: Express.User | undefined,
opts: { generateTitle?: (body: string, ownerId?: string) => Promise<string> } = {},
): express.Application {
const app = express();
if (user) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
}
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
generateTitle: opts.generateTitle,
});
return app;
}
// Create a task owned by owner-1 directly through the repo and return its id.
// ownerId is stored as a raw string column (no FK to users needed for these
// route checks, which compare task.ownerId === req.user.id), so we set it
// directly without seeding a users row.
async function seedTask(body = 'Investigate the flaky test in CI'): Promise<number> {
const created = await repo.createLocalTask({
title: 'seed task',
body,
pieceName: 'auto',
ownerId: 'owner-1',
visibility: 'private',
});
return created.id;
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-mission-title-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
describe('PUT /api/local/tasks/:taskId/mission', () => {
it('persists string mission fields and reflects them on GET', async () => {
const id = await seedTask();
const app = makeApp(makeUser());
const res = await request(app)
.put(`/api/local/tasks/${id}/mission`)
.send({ goal: 'Make CI green', done: 'all tests pass', open: 'which test', clarifications: '' });
expect(res.status).toBe(200);
expect(res.body.missionBrief).toMatchObject({
goal: 'Make CI green', done: 'all tests pass', open: 'which test', clarifications: '',
});
// Persisted: a fresh GET reflects the saved brief.
const got = await request(app).get(`/api/local/tasks/${id}`);
expect(got.status).toBe(200);
expect(got.body.task.missionBrief).toMatchObject({ goal: 'Make CI green', done: 'all tests pass' });
});
it('partial-replaces: only provided string fields change, others are kept', async () => {
const id = await seedTask();
const app = makeApp(makeUser());
await request(app).put(`/api/local/tasks/${id}/mission`).send({ goal: 'first goal', open: 'q1' });
const res = await request(app).put(`/api/local/tasks/${id}/mission`).send({ goal: 'second goal' });
expect(res.status).toBe(200);
expect(res.body.missionBrief.goal).toBe('second goal');
// `open` was not sent → left unchanged.
expect(res.body.missionBrief.open).toBe('q1');
});
it('rejects a body with no string mission fields (400)', async () => {
const id = await seedTask();
const app = makeApp(makeUser());
// Non-string values are ignored → empty patch → 400.
const res = await request(app).put(`/api/local/tasks/${id}/mission`).send({ goal: 123, foo: 'bar' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('No mission fields');
});
it('denies a non-owner (404, no leak)', async () => {
const id = await seedTask();
const app = makeApp(makeUser({ id: 'intruder' }));
const res = await request(app).put(`/api/local/tasks/${id}/mission`).send({ goal: 'hijack' });
// checkTaskOwnership → 404 (private task invisible to non-owner; not 403).
expect(res.status).toBe(404);
});
it('allows an admin to update any task mission', async () => {
const id = await seedTask();
const app = makeApp(makeUser({ id: 'admin-1', role: 'admin' }));
const res = await request(app).put(`/api/local/tasks/${id}/mission`).send({ goal: 'admin set' });
expect(res.status).toBe(200);
expect(res.body.missionBrief.goal).toBe('admin set');
});
it('400 for a malformed taskId', async () => {
const app = makeApp(makeUser());
const res = await request(app).put('/api/local/tasks/not-a-number/mission').send({ goal: 'x' });
expect(res.status).toBe(400);
});
});
describe('POST /api/local/tasks/:taskId/regenerate-title', () => {
it('regenerates the title for the owner via the generator', async () => {
const id = await seedTask('Some long task body that needs a snappy title');
const generateTitle = vi.fn().mockResolvedValue(' Snappy Title ');
const app = makeApp(makeUser(), { generateTitle });
const res = await request(app).post(`/api/local/tasks/${id}/regenerate-title`);
expect(res.status).toBe(200);
expect(res.body.title).toBe('Snappy Title'); // trimmed
expect(generateTitle).toHaveBeenCalledWith('Some long task body that needs a snappy title', 'owner-1');
const got = await request(app).get(`/api/local/tasks/${id}`);
expect(got.body.task.title).toBe('Snappy Title');
});
it('falls back to the synchronous title when the model returns empty', async () => {
const id = await seedTask('Fallback body content');
const generateTitle = vi.fn().mockResolvedValue(' ');
const app = makeApp(makeUser(), { generateTitle });
const res = await request(app).post(`/api/local/tasks/${id}/regenerate-title`);
expect(res.status).toBe(200);
// Empty model output is not an error — a non-empty fallback title is returned.
expect(typeof res.body.title).toBe('string');
expect(res.body.title.length).toBeGreaterThan(0);
});
it('denies a non-owner (404)', async () => {
const id = await seedTask();
const generateTitle = vi.fn().mockResolvedValue('Should not run');
const app = makeApp(makeUser({ id: 'intruder' }), { generateTitle });
const res = await request(app).post(`/api/local/tasks/${id}/regenerate-title`);
expect(res.status).toBe(404);
expect(generateTitle).not.toHaveBeenCalled();
});
it('allows an admin to regenerate any task title', async () => {
const id = await seedTask();
const generateTitle = vi.fn().mockResolvedValue('Admin Title');
const app = makeApp(makeUser({ id: 'admin-1', role: 'admin' }), { generateTitle });
const res = await request(app).post(`/api/local/tasks/${id}/regenerate-title`);
expect(res.status).toBe(200);
expect(res.body.title).toBe('Admin Title');
});
it('returns 503 when no title generator is configured', async () => {
const id = await seedTask();
const app = makeApp(makeUser()); // no generateTitle
const res = await request(app).post(`/api/local/tasks/${id}/regenerate-title`);
expect(res.status).toBe(503);
expect(res.body.error).toContain('not configured');
});
it('returns 502 when the generator throws', async () => {
const id = await seedTask();
const generateTitle = vi.fn().mockRejectedValue(new Error('model down'));
const app = makeApp(makeUser(), { generateTitle });
const res = await request(app).post(`/api/local/tasks/${id}/regenerate-title`);
expect(res.status).toBe(502);
});
});

View File

@ -213,6 +213,13 @@ describe('tool-request decide flow (no-auth)', () => {
expect(res.status).toBe(400);
});
it('rejects a normal message while a job is parked for tool approval (no duplicate-job race)', async () => {
const { taskId } = await makeTaskWithPendingRequest();
const res = await request(app).post(`/api/local/tasks/${taskId}/comments`).send({ body: 'just a normal message' });
expect(res.status).toBe(409);
expect(String(res.body.error ?? '')).toMatch(/ツール要求/);
});
it('does NOT resume a job parked without wait_reason=tool_request (worker-park contract guard)', async () => {
// Encodes the failure mode where the worker forgot to persist
// wait_reason='tool_request': the approve still records the grant, but the

View File

@ -1,17 +1,12 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { type Application } from 'express';
import { Repository } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { logger } from '../logger.js';
import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
import { canEditInSpace } from './visibility.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js';
import { buildTitleFallback } from '../title-generation.js';
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
import { spaceRunsDir } from '../spaces/runtime-paths.js';
import { type LocalTasksDeps } from './local-tasks-shared.js';
import { registerLocalTaskCrudRoutes } from './local-tasks-crud-api.js';
import { registerLocalTaskCommentsRoutes } from './local-tasks-comments-api.js';
import { registerLocalTaskToolRequestRoutes } from './local-tasks-tool-requests-api.js';
import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js';
import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js';
export interface LocalTasksApiOptions {
repo: Repository;
@ -68,917 +63,30 @@ export interface LocalTasksApiOptions {
evaluatePromptTimeoutMs?: number;
}
/**
* Mount the /api/local/tasks/* routes. The route handlers live in focused
* sibling modules (crud / comments / tool-requests / control / stream); this
* function only builds the shared dependency bundle and registers each group.
*/
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
const { repo, worktreeDir, sessRepo } = opts;
const userFolderRoot = opts.userFolderRoot ?? './data/users';
// No-auth single-user mode: register new tasks/jobs under the 'local' owner
// (the same namespace pieces/memory/reflection use) rather than letting the
// missing req.user fall through to a NULL owner. In auth mode this is
// undefined, so `req.user.id ?? noAuthOwner` keeps the real owner.
const noAuthOwner: string | undefined = (opts.authActive ?? true) ? undefined : 'local';
const resolveUploadLimit = (): string => {
const raw = opts.getMaxUploadMb?.() ?? 50;
const mb = Number.isFinite(raw) ? Math.max(1, Math.min(1000, Math.floor(raw))) : 50;
return `${mb}mb`;
};
const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) =>
express.json({ limit: resolveUploadLimit() })(req, res, next);
// 共有ワークスペース対応の書き込みゲート。owner/admin に加え、タスクが属する
// スペースの editor 以上のメンバーにも書き込みを許す。判定は 3 値:
// 'allow' : owner/admin もしくは editor+ メンバー → 書き込み可
// 'view-only' : 閲覧はできるが書き込み権限がないspace の viewer ロール)→ 403
// 'deny' : 閲覧すらできない(非メンバー・他人の private→ 404
// 呼び出し側は view-only と deny を別々のステータスコードに割り当てる。
// owner-only のままにすべき破壊的操作DELETE 等)はこのゲートを使わず
// 従来通り checkTaskOwnership を使う。
type WriteGate = 'allow' | 'view-only' | 'deny';
const resolveTaskWriteGate = async (
viewer: Express.User | undefined,
task: { ownerId?: string | null; spaceId?: string | null } | null,
): Promise<WriteGate> => {
if (!task) return 'deny';
// no-authviewer 不在は従来通り素通りsynthetic local owner
if (!viewer) return 'allow';
if (viewer.role === 'admin') return 'allow';
if (task.ownerId && task.ownerId === viewer.id) return 'allow';
// スペースタスクならメンバーシップで判定。
if (task.spaceId) {
// getSpace は buildSpaceVisibilityWhere 経由。case スペースはメンバー
// editor/viewer どちらも)に可視なので、ここで space が取れる=閲覧可。
const space = await repo.getSpace(task.spaceId, { viewer });
if (space) {
const memberRole = repo.getSpaceMemberRole(task.spaceId, viewer.id);
if (canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return 'allow';
}
// 閲覧はできるが editor 未満viewer ロール / 根オーナーでもない)→ 403。
return 'view-only';
}
}
return 'deny';
const deps: LocalTasksDeps = {
repo: opts.repo,
worktreeDir: opts.worktreeDir,
userFolderRoot: opts.userFolderRoot ?? './data/users',
sessRepo: opts.sessRepo,
noAuthOwner,
opts,
};
app.get('/api/local/tasks', async (req: Request, res: Response) => {
try {
const viewer = req.user as Express.User | undefined;
const tasks = await repo.listLocalTasks(viewer ? { viewer } : {});
res.json({ tasks });
} catch (err) {
logger.error(`Local tasks list API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local tasks' });
}
});
app.post('/api/local/tasks', dynamicJson(), async (req: Request, res: Response) => {
try {
const validation = validateCreateTaskBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const body = validation.data;
// Visibility extraction + validation
const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const visibility = rawVisibility as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
// Optional browser session profile binding. Owner-scoped check
// (sessRepo.getProfileById enforces owner_id = req.user.id) prevents
// user A from binding user B's profile to their task.
let browserSessionProfileId: number | null = null;
const rawProfileId = req.body?.browserSessionProfileId;
if (rawProfileId !== undefined && rawProfileId !== null && rawProfileId !== '') {
const n = Number(rawProfileId);
if (!Number.isInteger(n) || n <= 0) {
res.status(400).json({ error: 'browserSessionProfileId must be a positive integer' });
return;
}
if (sessRepo) {
const userId = (req.user as Express.User | undefined)?.id;
if (!userId) {
res.status(400).json({ error: 'browserSessionProfileId requires an authenticated user' });
return;
}
const owned = sessRepo.getProfileById(n, userId);
if (!owned) {
res.status(400).json({ error: 'browser session profile not found or not owned by you' });
return;
}
}
browserSessionProfileId = n;
}
const userTitle = (body.title ?? '').trim();
const rawPiece = (body.piece ?? 'auto').trim();
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
// Title is NOT generated by an LLM at creation time anymore — that fired a
// second concurrent LLM request per task and churned gateway backend
// slots. Instead we set a cheap synchronous fallback now, and the agent
// upgrades it during the run by deriving from the Mission Brief goal
// (see Repository.updateMissionBriefSync). On-demand AI regeneration is
// available via POST /api/local/tasks/:id/regenerate-title.
const autoSelectedPiece = (rawPiece === 'auto' && opts.selectPiece)
? await opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id)
.catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
: rawPiece;
const taskTitle = userTitle || buildTitleFallback(body.body.trim());
const titleSource: 'auto' | 'user' = userTitle ? 'user' : 'auto';
const piece = autoSelectedPiece;
const profile = body.profile ?? 'auto';
const outputFormat = body.outputFormat ?? 'markdown';
const askPolicy = body.askPolicy ?? 'low';
const priority = body.priority ?? 'medium';
const scheduling = resolveJobScheduling({
role: profile,
pieceName: piece,
instruction: body.body.trim(),
});
// Per-task options (e.g. { mcpDisabled, skillsDisabled })
const rawOptions = req.body?.options;
const taskOptions: Record<string, unknown> =
rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
? rawOptions as Record<string, unknown>
: {};
// spaceId は閲覧できるスペースのみ受理する(見えない/存在しない ID は
// 個人スペース既定に倒す)。これで他スペースのワークスペースへの紛れ込みを防ぐ。
let resolvedSpaceId: string | null = null;
if (body.spaceId) {
const reqSpace = await repo.getSpace(body.spaceId, { viewer: req.user as Express.User | undefined });
if (reqSpace) {
resolvedSpaceId = reqSpace.id;
} else {
logger.warn(`[local-tasks] create: spaceId=${body.spaceId} not visible to user; falling back to personal space`);
}
}
// Tasks created inside a space are ALWAYS private: access is granted
// exclusively by space membership (the shared-space OR-branch in
// buildVisibilityWhere lets every member see all rows of their space).
// Forcing private here prevents an org/public selection from leaking the
// chat to non-members. Per-chat visibility is intentionally not selectable.
const effectiveVisibility = resolvedSpaceId ? 'private' : visibility;
const effectiveScopeOrgId = effectiveVisibility === 'org' ? visibilityScopeOrgId : null;
const task = await repo.createLocalTask({
title: taskTitle,
titleSource,
body: body.body.trim(),
pieceName: piece,
profile,
outputFormat,
askPolicy,
priority,
workspaceMode: body.workspaceMode,
spaceId: resolvedSpaceId,
ownerId: req.user?.id ?? noAuthOwner,
visibility: effectiveVisibility,
visibilityScopeOrgId: effectiveScopeOrgId,
browserSessionProfileId,
options: taskOptions,
});
// 実効スペースを解決し、mode に応じてワークスペースパスを決める。
// 既定は persistent個人/案件スペースの共有ツリー。ephemeral は使い捨て。
// resolveTaskFolderContext は副作用で個人スペース生成も担保する。
const mode = body.workspaceMode ?? 'persistent';
await repo.resolveTaskFolderContext(task.id, userFolderRoot);
const space = task.spaceId
? await repo.getSpace(task.spaceId)
: await repo.ensurePersonalSpace(task.ownerId ?? 'local');
const effectiveWorktreeDir = worktreeDir ?? './data/worktrees';
const workspacePath = space
? resolveTaskWorkspaceDir(mode, space, effectiveWorktreeDir, task.id)
: getLocalWorkspacePath(worktreeDir, task.id); // フォールバック(スペース解決不能時のみ)
ensureWorkspaceDirs(workspacePath);
// 計画5 (Phase B): persistent スペースタスクは実行ログを成果物ツリーから分離し、
// タスク単位の {worktreeDir}/space/{id}/runs/{taskId} に書く。ephemeral は
// runtime_dir=null のままで、worker/runPiece が ephemeral/{taskId}/logs に
// フォールバックする(後方互換)。
let runtimeDir: string | undefined;
if (mode === 'persistent' && space) {
runtimeDir = spaceRunsDir(effectiveWorktreeDir, space.id, task.id);
mkdirSync(runtimeDir, { recursive: true });
}
await repo.updateLocalTask(task.id, { workspacePath, ...(runtimeDir ? { runtimeDir } : {}) });
// Save attachments to input/. The resulting (sanitized) filenames are
// stored on the initial request comment so the UI can render download
// links, and reused below to tell the agent which files landed in input/.
// (コメント追加パス POST :id/comments と同じ挙動に揃える)
const savedFileNames = (body.attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
for (const att of body.attachments ?? []) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
}
await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request', savedFileNames);
const metadataBlock = [
'---',
`ui_profile: ${scheduling.role}`,
`ui_output_format: ${outputFormat}`,
`ui_ask_policy: ${askPolicy}`,
`ui_priority: ${priority}`,
'---',
].join('\n');
const attachmentBlock = savedFileNames.length > 0
? `添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}\n\n`
: '';
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${attachmentBlock}${metadataBlock}`.trim();
// Merge task options into job payload so the worker can read them at runtime.
const hasOptions = Object.keys(taskOptions).length > 0;
const job = await repo.createJob({
repo: localTaskRepoName(task.id),
issueNumber: task.id,
instruction,
pieceName: piece,
role: scheduling.role,
ownerId: task.ownerId,
visibility: task.visibility,
visibilityScopeOrgId: task.visibilityScopeOrgId,
browserSessionProfileId: task.browserSessionProfileId ?? null,
spaceId: task.spaceId ?? null,
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
});
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
if (rawPiece === 'auto') {
await repo.addAuditLog(job.id, 'piece_auto_selected', 'piece-classifier', {
selectedPiece: piece,
});
}
const created = await repo.getLocalTask(task.id);
res.status(201).json({ task: created, jobId: job.id });
} catch (err) {
logger.error(`Create local task API error: ${err}`);
res.status(500).json({ error: 'Failed to create local task' });
}
});
app.get('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ task });
} catch (err) {
logger.error(`Local task detail API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task' });
}
});
// Tool-request mechanism: list the tool requests recorded for a task
// (RequestTool declarations + passively-captured blocked calls).
app.get('/api/local/tasks/:taskId/tool-requests', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ toolRequests: repo.listToolRequestsByTask(String(taskId)) });
} catch (err) {
logger.error(`Tool requests list API error: ${err}`);
res.status(500).json({ error: 'Failed to list tool requests' });
}
});
// Tool-request mechanism: approve (grant for this task + resume) or deny a
// pending tool request. Requires task write permission (owner / admin /
// space editor). Approving grants the tool for the rest of THIS task; the
// permanent fix (adding it to the piece) is a separate one-click action in
// the piece editor's aggregation view.
app.post('/api/local/tasks/:taskId/tool-requests/:reqId/decide', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const reqId = String(req.params.reqId);
const decision = (req.body as { decision?: unknown })?.decision;
if (decision !== 'approve' && decision !== 'deny') {
res.status(400).json({ error: 'decision must be "approve" or "deny"' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
const gate = await resolveTaskWriteGate(viewer, task ? { ownerId: task.ownerId, spaceId: task.spaceId } : null);
if (gate !== 'allow') {
res.status(gate === 'view-only' ? 403 : 404)
.json({ error: gate === 'view-only' ? 'No permission to decide tool requests' : 'Task not found' });
return;
}
const tr = repo.getToolRequest(reqId);
if (!tr || tr.taskId !== String(taskId)) { res.status(404).json({ error: 'Tool request not found' }); return; }
if (tr.status !== 'pending') { res.status(409).json({ error: `Already decided (${tr.status})` }); return; }
const decidedBy = viewer?.id ?? null;
if (decision === 'approve') {
repo.addGrantedTool(String(taskId), tr.toolName);
repo.decideToolRequest(reqId, { status: 'approved', grantScope: 'task', decidedBy });
} else {
repo.decideToolRequest(reqId, { status: 'denied', decidedBy });
}
// Resume the parked job so the agent continues (with or without the tool).
const resumed = tr.jobId ? repo.resumeToolRequestJob(tr.jobId) : 0;
res.json({ ok: true, decision, toolName: tr.toolName, resumed: resumed > 0 });
} catch (err) {
logger.error(`Tool request decide API error: ${err}`);
res.status(500).json({ error: 'Failed to decide tool request' });
}
});
app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const validation = validateFeedbackBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
await repo.updateFeedback(taskId, validation.data);
const updated = await repo.getLocalTask(taskId);
res.json({ task: updated });
} catch (err) {
logger.error(`Local task feedback API error: ${err}`);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
app.put('/api/local/tasks/:taskId/mission', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Partial-replace: only string fields are written. Anything else
// (null, undefined, non-string) is treated as "leave unchanged".
// To clear a field, send an empty string.
const body = (req.body ?? {}) as Record<string, unknown>;
const patch: Record<string, string> = {};
for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
const v = body[key];
if (typeof v === 'string') patch[key] = v;
}
if (Object.keys(patch).length === 0) {
res.status(400).json({ error: 'No mission fields provided. Send goal, done, open, or clarifications as strings.' });
return;
}
const merged = await repo.updateMissionBrief(taskId, patch);
res.json({ missionBrief: merged });
} catch (err) {
logger.error(`Local task mission API error: ${err}`);
res.status(500).json({ error: 'Failed to update mission brief' });
}
});
app.get('/api/local/tasks/:taskId/comments', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
const comments = await repo.listLocalTaskComments(taskId);
res.json({ comments });
} catch (err) {
logger.error(`Local task comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task comments' });
}
});
app.post('/api/local/tasks/:taskId/comments', dynamicJson(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const commentValidation = validateCommentBody(req.body);
if (!commentValidation.valid) {
res.status(400).json({ error: commentValidation.error });
return;
}
const { body, author, attachments } = commentValidation;
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
// 共有ワークスペースのタスクには editor 以上のメンバーもコメント可能。
// owner/admin/editor+ → 投稿可、閲覧のみviewer ロール)→ 明確な 403、
// 閲覧すらできない(非メンバー)→ 404。
const writeGate = await resolveTaskWriteGate(viewer, task);
if (writeGate === 'deny') {
res.status(404).json({ error: 'Task not found' });
return;
}
if (writeGate === 'view-only') {
res.status(403).json({ error: '閲覧のみのため投稿できません' });
return;
}
// Save attachments to input/. The resulting (sanitized) filenames are
// stored on the comment so the UI can render download links, and reused
// below to tell the agent which files landed in input/.
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
if (attachments && attachments.length > 0 && task?.workspacePath) {
const inputDir = join(task.workspacePath, 'input');
mkdirSync(inputDir, { recursive: true });
for (const att of attachments) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(inputDir, safeName), Buffer.from(att.contentBase64, 'base64'));
}
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
// A job actively running injects the comment at its next iteration — save
// it as an interjection and don't touch the job queue.
const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
const commentKind = isRunning ? 'interjection' : 'comment';
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind, savedFileNames);
if (isRunning) {
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
return;
}
const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
// Build instruction with attachment info (savedFileNames computed above)
const instruction = savedFileNames.length > 0
? `${body}\n\n添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}`
: body;
// Atomically reuse a still-pending job (e.g. a queued job from a comment
// sent a moment earlier) or create one. Without this, rapid/concurrent
// comments each spawned a job; the newest (queued) became latestJob and the
// task showed "Inbox" while an older job ran in the background.
const { job, created } = repo.createJobIfNoPending({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction,
pieceName: task!.pieceName,
askCount,
resumeMovement,
role: prevJob?.requiredRole,
ownerId: task!.ownerId,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
spaceId: task!.spaceId ?? null,
});
if (created) {
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
} else {
logger.info(`[local-tasks-api] comment ${comment.id} appended to pending job ${job.id} (${job.status}) on task ${taskId}; no duplicate job created`);
}
res.status(201).json({ comment, jobId: job.id, reusedPending: !created });
} catch (err) {
logger.error(`Local task comment create API error: ${err}`);
res.status(500).json({ error: 'Failed to post local task comment' });
}
});
app.patch('/api/local/tasks/:taskId', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
if (req.body.title !== undefined) {
if (typeof req.body.title !== 'string') {
res.status(400).json({ error: 'title must be a string' }); return;
}
const trimmed = req.body.title.trim();
if (!trimmed) { res.status(400).json({ error: 'title must not be empty' }); return; }
if (trimmed.length > 200) { res.status(400).json({ error: 'title must be 200 characters or less' }); return; }
// Manual edit pins the title: the agent never auto-overwrites a user title.
updates.title = trimmed;
updates.titleSource = 'user';
}
if (req.body.visibility !== undefined) {
const v = req.body.visibility;
if (!['private', 'org', 'public'].includes(v)) {
res.status(400).json({ error: 'invalid visibility' }); return;
}
updates.visibility = v;
}
if (req.body.visibilityScopeOrgId !== undefined) {
updates.visibilityScopeOrgId = req.body.visibilityScopeOrgId ?? null;
}
if (updates.visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
const scopeId = updates.visibilityScopeOrgId ?? task!.visibilityScopeOrgId ?? null;
if (!scopeId || !orgIds.includes(scopeId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return;
}
updates.visibilityScopeOrgId = scopeId;
}
if (updates.visibility && updates.visibility !== 'org') {
updates.visibilityScopeOrgId = null;
}
await repo.updateLocalTask(taskId, updates);
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
await repo.updateJobsVisibilityForTask(taskId, {
visibility: refreshed.visibility ?? 'private',
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
});
}
res.json({ task: refreshed });
} catch (err) {
logger.error(`Patch local task API error: ${err}`);
res.status(500).json({ error: 'Failed to update task' });
}
});
// On-demand AI title regeneration. Unlike the old creation-time path this
// only fires when the user explicitly asks (a button), so it never adds a
// concurrent LLM request to the task-creation hot path. Owner/admin only.
app.post('/api/local/tasks/:taskId/regenerate-title', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
if (!opts.generateTitle) { res.status(503).json({ error: 'Title generation is not configured' }); return; }
let title = '';
try {
title = await Promise.race([
// Ownerless (no-auth) tasks attribute to 'local', matching the
// worker/piece-runner convention (ownerId ?? 'local').
opts.generateTitle(task!.body, task!.ownerId ?? 'local'),
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
]);
} catch (e) {
logger.warn(`Title regeneration failed (task=${taskId}): ${e}`);
res.status(502).json({ error: 'Title generation failed' }); return;
}
// Empty model output is not an error: fall back to the cheap synchronous
// title so the button always yields something (matching the old creation
// path's behaviour).
title = (title ?? '').trim() || buildTitleFallback(task!.body);
await repo.updateLocalTask(taskId, { title, titleSource: 'agent' });
res.json({ title });
} catch (err) {
logger.error(`Regenerate title API error: ${err}`);
res.status(500).json({ error: 'Failed to regenerate title' });
}
});
// On-demand prompt coach. Evaluates a draft prompt before the task is
// created (stateless), so it never touches the DB or piece-runner. The owner
// context (memory / AGENTS.md / skills / visible pieces) is keyed on the
// requesting user, falling back to 'local' for unauthenticated/no-auth calls.
app.post('/api/local/tasks/evaluate-prompt', dynamicJson(), async (req: Request, res: Response) => {
try {
if (!opts.evaluatePrompt) {
res.status(503).json({ error: 'Prompt coach is not configured' });
return;
}
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
if (!instruction.trim()) {
res.status(400).json({ error: 'instruction is required' });
return;
}
const piece = typeof req.body?.piece === 'string' ? req.body.piece : undefined;
const userId = (req.user as Express.User | undefined)?.id ?? 'local';
// Abort the underlying LLM stream on timeout so a slow model doesn't keep
// consuming tokens/connections in the background after we've given up.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutMs = opts.evaluatePromptTimeoutMs ?? 30000;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error('timeout'));
}, timeoutMs);
});
try {
const result = await Promise.race([
opts.evaluatePrompt({ instruction, piece, userId, signal: controller.signal }),
timeout,
]);
res.json(result);
} finally {
if (timer) clearTimeout(timer);
}
} catch (err) {
logger.warn(`Prompt coach evaluation failed: ${err}`);
res.status(502).json({ error: 'Prompt evaluation failed' });
}
});
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
await repo.deleteLocalTask(taskId, worktreeDir);
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('has an active job')) {
res.status(409).json({ error: 'Cannot delete task with running jobs' });
return;
}
logger.error(`Delete local task API error: ${err}`);
res.status(500).json({ error: 'Failed to delete local task' });
}
});
app.post('/api/local/tasks/:taskId/cancel', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
// コメントと同じく、共有ワークスペースの editor 以上のメンバーは
// 暴走ジョブを止められるべき。owner/admin/editor+ → 可、viewer → 403、
// 非メンバー → 404。
const cancelGate = await resolveTaskWriteGate(viewer, task);
if (cancelGate === 'deny') {
res.status(404).json({ error: 'Task not found' });
return;
}
if (cancelGate === 'view-only') {
res.status(403).json({ error: '閲覧のみのため操作できません' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
res.status(404).json({ error: 'No running job found' });
return;
}
const cancelled = repo.requestJobCancel(latestJob.id);
if (!cancelled) {
res.status(409).json({ error: 'Job is no longer running' });
return;
}
await repo.addAuditLog(latestJob.id, 'job_cancel_requested', 'local-ui', { taskId });
logger.info(`Cancel requested for job ${latestJob.id} (task ${taskId})`);
res.json({ ok: true, jobId: latestJob.id });
} catch (err) {
logger.error(`Cancel local task API error: ${err}`);
res.status(500).json({ error: 'Failed to cancel task' });
}
});
app.post('/api/local/tasks/:taskId/continue', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const piece = typeof req.body?.piece === 'string' ? req.body.piece.trim() : '';
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
if (!piece) {
res.status(400).json({ error: 'piece_required' });
return;
}
if (!instruction.trim()) {
res.status(400).json({ error: 'instruction_required' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Piece existence check (server-side; UI dropdown is best-effort).
if (!opts.pieceExists) {
logger.error('[local-tasks-api] /continue invoked but pieceExists option not configured');
res.status(500).json({ error: 'piece_validation_unavailable' });
return;
}
if (!opts.pieceExists(piece, task?.ownerId ?? undefined)) {
res.status(400).json({ error: 'piece_not_found', piece });
return;
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!prevJob) {
res.status(409).json({ error: 'no_previous_job' });
return;
}
// jobs.status CHECK には 'aborted' が無い (worker が abort 結果を 'failed' に集約するため)。
// 'waiting_subtasks' は子 job 待機の中間状態で、そこから別 piece に切り替えると孤立するので除外。
const TERMINAL: ReadonlyArray<string> = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
if (!TERMINAL.includes(prevJob.status)) {
res.status(409).json({ error: 'job_in_progress', currentStatus: prevJob.status });
return;
}
const job = await repo.createJob({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction: instruction.trim(),
pieceName: piece,
continuedFromJobId: prevJob.id,
ownerId: task!.ownerId,
role: prevJob.requiredRole,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
spaceId: task!.spaceId ?? null,
});
await repo.updateLocalTask(taskId, { pieceName: piece });
// Persist the switch-time instruction as a user request. Without this it
// lives only in job.instruction, and buildLocalConversationContext picks
// the *latest user comment* as the current instruction — so a stale older
// comment would win and the switch text would be demoted to the "original
// task (possibly already handled)" slot, making the agent re-follow prior
// instructions instead of the new one. Mirrors the create path, which
// also persists the body as a 'request' comment.
await repo.addLocalTaskComment(taskId, 'user', instruction.trim(), 'request');
// Surface the handoff in the timeline so the user (and the LLM, when
// it later inspects task comments) can see when piece switches happened.
await repo.addLocalTaskComment(
taskId,
'system',
`🔄 Continued: piece="${prevJob.pieceName}" → piece="${piece}"`,
'handoff',
);
await repo.addAuditLog(job.id, 'job_queued_local_continue', 'local-ui', {
taskId,
fromPiece: prevJob.pieceName,
toPiece: piece,
prevJobId: prevJob.id,
});
res.status(201).json({ jobId: job.id });
} catch (err) {
logger.error(`Local task continue API error: ${err}`);
res.status(500).json({ error: 'Failed to continue task' });
}
});
// ── SSE stream: real-time job events ──────────────────────────────────────
app.get('/api/local/tasks/:taskId/stream', async (req: Request, res: Response) => {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'invalid taskId' }); return; }
try {
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : {});
if (!task) { res.status(404).json({ error: 'task not found' }); return; }
const runningJob = task.latestJob;
if (!runningJob || (runningJob.status !== 'running' && runningJob.status !== 'dispatching')) {
res.status(204).end();
return;
}
const jobId = runningJob.id;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Text delta batching (50ms flush)
let textBuf = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const TEXT_FLUSH_MS = 50;
// Tool-call argument delta batching, keyed by callId (50ms flush).
const toolBuf = new Map<string, { name: string; chunk: string }>();
let toolFlushTimer: ReturnType<typeof setTimeout> | null = null;
const flushText = () => {
if (textBuf) {
const data = JSON.stringify({ type: 'text_delta', text: textBuf });
res.write(`data: ${data}\n\n`);
textBuf = '';
}
flushTimer = null;
};
const flushToolDeltas = () => {
for (const [callId, { name, chunk }] of toolBuf) {
if (res.writableEnded) break;
res.write(`data: ${JSON.stringify({ type: 'tool_use_delta', callId, name, chunk })}\n\n`);
}
toolBuf.clear();
toolFlushTimer = null;
};
const handler = (event: JobStreamEvent) => {
if (res.writableEnded) return;
if (event.type === 'text') {
textBuf += event.text ?? '';
if (!flushTimer) flushTimer = setTimeout(flushText, TEXT_FLUSH_MS);
return;
}
if (event.type === 'tool_use_delta') {
const callId = event.callId ?? '';
// chunk is a full snapshot of args-so-far; keep the LATEST per
// callId (replace, not append) so each flush sends the newest
// complete prefix. Coalesces many snapshots into one per 50ms.
toolBuf.set(callId, {
name: event.name ?? toolBuf.get(callId)?.name ?? '',
chunk: event.chunk ?? '',
});
if (!toolFlushTimer) toolFlushTimer = setTimeout(flushToolDeltas, TEXT_FLUSH_MS);
return;
}
// Flush pending text + tool deltas before non-streaming events
if (textBuf) flushText();
if (toolBuf.size) flushToolDeltas();
if (event.type === 'prompt_progress') {
const effective = (event.processed ?? 0) - (event.cache ?? 0);
const effectiveTotal = (event.total ?? 0) - (event.cache ?? 0);
const percent = effectiveTotal > 0 ? Math.round(effective / effectiveTotal * 100) : 0;
res.write(`data: ${JSON.stringify({ type: 'prompt_progress', percent, processed: event.processed, total: event.total, cache: event.cache, timeMs: event.timeMs })}\n\n`);
} else if (event.type === 'done') {
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
cleanup();
res.end();
} else {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
};
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': heartbeat\n\n');
}, 15_000);
const cleanup = () => {
jobEventBus.offJob(jobId, handler);
clearInterval(heartbeat);
if (flushTimer) { clearTimeout(flushTimer); flushText(); }
if (toolFlushTimer) { clearTimeout(toolFlushTimer); flushToolDeltas(); }
};
jobEventBus.onJob(jobId, handler);
req.on('close', cleanup);
} catch (err) {
logger.error(`Local task stream API error: ${err}`);
if (!res.headersSent) res.status(500).json({ error: 'stream failed' });
}
});
registerLocalTaskCrudRoutes(app, deps);
registerLocalTaskToolRequestRoutes(app, deps);
registerLocalTaskCommentsRoutes(app, deps);
registerLocalTaskControlRoutes(app, deps);
registerLocalTaskStreamRoutes(app, deps);
}

View File

@ -0,0 +1,222 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId, validateCommentBody, validateFeedbackBody } from './validation.js';
import { checkTaskOwnership, canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
import { type LocalTasksDeps, makeDynamicJson, resolveTaskWriteGate } from './local-tasks-shared.js';
/**
* Conversation + per-task state: feedback / mission brief / comments (read &
* post, including interjection and tool-request gating).
*/
export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTasksDeps): void {
const { repo } = deps;
const dynamicJson = makeDynamicJson(deps.opts.getMaxUploadMb);
app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const validation = validateFeedbackBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
await repo.updateFeedback(taskId, validation.data);
const updated = await repo.getLocalTask(taskId);
res.json({ task: updated });
} catch (err) {
logger.error(`Local task feedback API error: ${err}`);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
app.put('/api/local/tasks/:taskId/mission', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Partial-replace: only string fields are written. Anything else
// (null, undefined, non-string) is treated as "leave unchanged".
// To clear a field, send an empty string.
const body = (req.body ?? {}) as Record<string, unknown>;
const patch: Record<string, string> = {};
for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
const v = body[key];
if (typeof v === 'string') patch[key] = v;
}
if (Object.keys(patch).length === 0) {
res.status(400).json({ error: 'No mission fields provided. Send goal, done, open, or clarifications as strings.' });
return;
}
const merged = await repo.updateMissionBrief(taskId, patch);
res.json({ missionBrief: merged });
} catch (err) {
logger.error(`Local task mission API error: ${err}`);
res.status(500).json({ error: 'Failed to update mission brief' });
}
});
app.get('/api/local/tasks/:taskId/comments', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
const comments = await repo.listLocalTaskComments(taskId);
res.json({ comments });
} catch (err) {
logger.error(`Local task comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task comments' });
}
});
app.post('/api/local/tasks/:taskId/comments', dynamicJson, async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const commentValidation = validateCommentBody(req.body);
if (!commentValidation.valid) {
res.status(400).json({ error: commentValidation.error });
return;
}
const { body, author, attachments } = commentValidation;
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
// 共有ワークスペースのタスクには editor 以上のメンバーもコメント可能。
// owner/admin/editor+ → 投稿可、閲覧のみviewer ロール)→ 明確な 403、
// 閲覧すらできない(非メンバー)→ 404。
const writeGate = await resolveTaskWriteGate(repo, viewer, task);
if (writeGate === 'deny') {
res.status(404).json({ error: 'Task not found' });
return;
}
if (writeGate === 'view-only') {
res.status(403).json({ error: '閲覧のみのため投稿できません' });
return;
}
// Tool-request mechanism: a job parked for tool approval must be resumed
// via approve/deny (POST .../tool-requests/:id/decide), NOT by a normal
// message — that would spawn a SECOND resume job running in parallel with
// the one the approval re-queues (duplicate execution). The UI locks the
// composer; guard here too so it's race-proof regardless of UI polling.
// Checked TWICE: once now (before writing attachments, so a rejected post
// leaves no orphan files in input/) and again after attachments (below)
// to close the TOCTOU where the job flips to tool_request mid-upload.
const earlyJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (earlyJob && earlyJob.status === 'waiting_human' && earlyJob.waitReason === 'tool_request') {
res.status(409).json({ error: '保留中のツール要求があります。先に許可または拒否してください。' });
return;
}
// Compute the (sanitized) attachment filenames now — they go into the
// comment and the instruction — but DEFER the actual file writes until
// after the tool_request gate passes (below), so a rejected post never
// leaves orphan files in input/.
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
const writeAttachments = () => {
if (attachments && attachments.length > 0 && task?.workspacePath) {
const inputDir = join(task.workspacePath, 'input');
mkdirSync(inputDir, { recursive: true });
for (const att of attachments) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(inputDir, safeName), Buffer.from(att.contentBase64, 'base64'));
}
}
};
// Re-fetch the latest job AFTER saving attachments for the interjection
// check below. The authoritative tool_request gate is enforced atomically
// inside createJobIfNoPending (same transaction as the insert) — see the
// blockOnToolRequestPause handling below — so there is no check-then-act
// race with the engine parking the job.
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
// A job actively running injects the comment at its next iteration — save
// it as an interjection and don't touch the job queue.
const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
if (isRunning) {
// Interjection: injected into the running job at its next iteration.
writeAttachments();
const comment = await repo.addLocalTaskComment(taskId, author, body, 'interjection', savedFileNames);
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
return;
}
const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
// Build instruction with attachment info (savedFileNames computed above)
const instruction = savedFileNames.length > 0
? `${body}\n\n添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}`
: body;
// Atomically reuse a still-pending job (e.g. a queued job from a comment
// sent a moment earlier) or create one. Without this, rapid/concurrent
// comments each spawned a job; the newest (queued) became latestJob and the
// task showed "Inbox" while an older job ran in the background.
// The tool_request gate is enforced inside the same transaction.
const { job, created, blockedByToolRequest } = repo.createJobIfNoPending({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction,
pieceName: task!.pieceName,
askCount,
resumeMovement,
role: prevJob?.requiredRole,
ownerId: task!.ownerId,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
spaceId: task!.spaceId ?? null,
}, { blockOnToolRequestPause: true });
// Blocked by a tool-approval pause → return BEFORE persisting the comment
// so a rejected post leaves no orphan comment tied to no job.
if (blockedByToolRequest) {
res.status(409).json({ error: '保留中のツール要求があります。先に許可または拒否してください。' });
return;
}
// Persist attachments + the comment only after the job decision succeeds.
writeAttachments();
const comment = await repo.addLocalTaskComment(taskId, author, body, 'comment', savedFileNames);
if (created) {
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
} else {
logger.info(`[local-tasks-api] comment ${comment.id} appended to pending job ${job.id} (${job.status}) on task ${taskId}; no duplicate job created`);
}
res.status(201).json({ comment, jobId: job.id, reusedPending: !created });
} catch (err) {
logger.error(`Local task comment create API error: ${err}`);
res.status(500).json({ error: 'Failed to post local task comment' });
}
});
}

View File

@ -0,0 +1,230 @@
import express, { type Application, type Request, type Response } from 'express';
import { localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership } from './local-api-helpers.js';
import { buildTitleFallback } from '../title-generation.js';
import { type LocalTasksDeps, makeDynamicJson, resolveTaskWriteGate } from './local-tasks-shared.js';
/**
* Job control + on-demand helpers: cancel / continue (piece switch) /
* regenerate-title / evaluate-prompt (prompt coach).
*/
export function registerLocalTaskControlRoutes(app: Application, deps: LocalTasksDeps): void {
const { repo } = deps;
const opts = deps.opts;
const dynamicJson = makeDynamicJson(opts.getMaxUploadMb);
app.post('/api/local/tasks/:taskId/cancel', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
// コメントと同じく、共有ワークスペースの editor 以上のメンバーは
// 暴走ジョブを止められるべき。owner/admin/editor+ → 可、viewer → 403、
// 非メンバー → 404。
const cancelGate = await resolveTaskWriteGate(repo, viewer, task);
if (cancelGate === 'deny') {
res.status(404).json({ error: 'Task not found' });
return;
}
if (cancelGate === 'view-only') {
res.status(403).json({ error: '閲覧のみのため操作できません' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
res.status(404).json({ error: 'No running job found' });
return;
}
const cancelled = repo.requestJobCancel(latestJob.id);
if (!cancelled) {
res.status(409).json({ error: 'Job is no longer running' });
return;
}
await repo.addAuditLog(latestJob.id, 'job_cancel_requested', 'local-ui', { taskId });
logger.info(`Cancel requested for job ${latestJob.id} (task ${taskId})`);
res.json({ ok: true, jobId: latestJob.id });
} catch (err) {
logger.error(`Cancel local task API error: ${err}`);
res.status(500).json({ error: 'Failed to cancel task' });
}
});
app.post('/api/local/tasks/:taskId/continue', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const piece = typeof req.body?.piece === 'string' ? req.body.piece.trim() : '';
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
if (!piece) {
res.status(400).json({ error: 'piece_required' });
return;
}
if (!instruction.trim()) {
res.status(400).json({ error: 'instruction_required' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Piece existence check (server-side; UI dropdown is best-effort).
if (!opts.pieceExists) {
logger.error('[local-tasks-api] /continue invoked but pieceExists option not configured');
res.status(500).json({ error: 'piece_validation_unavailable' });
return;
}
if (!opts.pieceExists(piece, task?.ownerId ?? undefined)) {
res.status(400).json({ error: 'piece_not_found', piece });
return;
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!prevJob) {
res.status(409).json({ error: 'no_previous_job' });
return;
}
// jobs.status CHECK には 'aborted' が無い (worker が abort 結果を 'failed' に集約するため)。
// 'waiting_subtasks' は子 job 待機の中間状態で、そこから別 piece に切り替えると孤立するので除外。
const TERMINAL: ReadonlyArray<string> = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
if (!TERMINAL.includes(prevJob.status)) {
res.status(409).json({ error: 'job_in_progress', currentStatus: prevJob.status });
return;
}
const job = await repo.createJob({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction: instruction.trim(),
pieceName: piece,
continuedFromJobId: prevJob.id,
ownerId: task!.ownerId,
role: prevJob.requiredRole,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
spaceId: task!.spaceId ?? null,
});
await repo.updateLocalTask(taskId, { pieceName: piece });
// Persist the switch-time instruction as a user request. Without this it
// lives only in job.instruction, and buildLocalConversationContext picks
// the *latest user comment* as the current instruction — so a stale older
// comment would win and the switch text would be demoted to the "original
// task (possibly already handled)" slot, making the agent re-follow prior
// instructions instead of the new one. Mirrors the create path, which
// also persists the body as a 'request' comment.
await repo.addLocalTaskComment(taskId, 'user', instruction.trim(), 'request');
// Surface the handoff in the timeline so the user (and the LLM, when
// it later inspects task comments) can see when piece switches happened.
await repo.addLocalTaskComment(
taskId,
'system',
`🔄 Continued: piece="${prevJob.pieceName}" → piece="${piece}"`,
'handoff',
);
await repo.addAuditLog(job.id, 'job_queued_local_continue', 'local-ui', {
taskId,
fromPiece: prevJob.pieceName,
toPiece: piece,
prevJobId: prevJob.id,
});
res.status(201).json({ jobId: job.id });
} catch (err) {
logger.error(`Local task continue API error: ${err}`);
res.status(500).json({ error: 'Failed to continue task' });
}
});
// On-demand AI title regeneration. Unlike the old creation-time path this
// only fires when the user explicitly asks (a button), so it never adds a
// concurrent LLM request to the task-creation hot path. Owner/admin only.
app.post('/api/local/tasks/:taskId/regenerate-title', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
if (!opts.generateTitle) { res.status(503).json({ error: 'Title generation is not configured' }); return; }
let title = '';
try {
title = await Promise.race([
// Ownerless (no-auth) tasks attribute to 'local', matching the
// worker/piece-runner convention (ownerId ?? 'local').
opts.generateTitle(task!.body, task!.ownerId ?? 'local'),
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
]);
} catch (e) {
logger.warn(`Title regeneration failed (task=${taskId}): ${e}`);
res.status(502).json({ error: 'Title generation failed' }); return;
}
// Empty model output is not an error: fall back to the cheap synchronous
// title so the button always yields something (matching the old creation
// path's behaviour).
title = (title ?? '').trim() || buildTitleFallback(task!.body);
await repo.updateLocalTask(taskId, { title, titleSource: 'agent' });
res.json({ title });
} catch (err) {
logger.error(`Regenerate title API error: ${err}`);
res.status(500).json({ error: 'Failed to regenerate title' });
}
});
// On-demand prompt coach. Evaluates a draft prompt before the task is
// created (stateless), so it never touches the DB or piece-runner. The owner
// context (memory / AGENTS.md / skills / visible pieces) is keyed on the
// requesting user, falling back to 'local' for unauthenticated/no-auth calls.
app.post('/api/local/tasks/evaluate-prompt', dynamicJson, async (req: Request, res: Response) => {
try {
if (!opts.evaluatePrompt) {
res.status(503).json({ error: 'Prompt coach is not configured' });
return;
}
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
if (!instruction.trim()) {
res.status(400).json({ error: 'instruction is required' });
return;
}
const piece = typeof req.body?.piece === 'string' ? req.body.piece : undefined;
const userId = (req.user as Express.User | undefined)?.id ?? 'local';
// Abort the underlying LLM stream on timeout so a slow model doesn't keep
// consuming tokens/connections in the background after we've given up.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutMs = opts.evaluatePromptTimeoutMs ?? 30000;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error('timeout'));
}, timeoutMs);
});
try {
const result = await Promise.race([
opts.evaluatePrompt({ instruction, piece, userId, signal: controller.signal }),
timeout,
]);
res.json(result);
} finally {
if (timer) clearTimeout(timer);
}
} catch (err) {
logger.warn(`Prompt coach evaluation failed: ${err}`);
res.status(502).json({ error: 'Prompt evaluation failed' });
}
});
}

View File

@ -0,0 +1,333 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
import { buildTitleFallback } from '../title-generation.js';
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
import { spaceRunsDir } from '../spaces/runtime-paths.js';
import { type LocalTasksDeps, makeDynamicJson } from './local-tasks-shared.js';
/**
* Core task lifecycle CRUD: list / create / detail / patch / delete.
*/
export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDeps): void {
const { repo, worktreeDir, sessRepo, userFolderRoot, noAuthOwner, opts } = deps;
const dynamicJson = makeDynamicJson(opts.getMaxUploadMb);
app.get('/api/local/tasks', async (req: Request, res: Response) => {
try {
const viewer = req.user as Express.User | undefined;
const tasks = await repo.listLocalTasks(viewer ? { viewer } : {});
res.json({ tasks });
} catch (err) {
logger.error(`Local tasks list API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local tasks' });
}
});
app.post('/api/local/tasks', dynamicJson, async (req: Request, res: Response) => {
try {
const validation = validateCreateTaskBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const body = validation.data;
// Visibility extraction + validation
const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const visibility = rawVisibility as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
// Optional browser session profile binding. Owner-scoped check
// (sessRepo.getProfileById enforces owner_id = req.user.id) prevents
// user A from binding user B's profile to their task.
let browserSessionProfileId: number | null = null;
const rawProfileId = req.body?.browserSessionProfileId;
if (rawProfileId !== undefined && rawProfileId !== null && rawProfileId !== '') {
const n = Number(rawProfileId);
if (!Number.isInteger(n) || n <= 0) {
res.status(400).json({ error: 'browserSessionProfileId must be a positive integer' });
return;
}
if (sessRepo) {
const userId = (req.user as Express.User | undefined)?.id;
if (!userId) {
res.status(400).json({ error: 'browserSessionProfileId requires an authenticated user' });
return;
}
const owned = sessRepo.getProfileById(n, userId);
if (!owned) {
res.status(400).json({ error: 'browser session profile not found or not owned by you' });
return;
}
}
browserSessionProfileId = n;
}
const userTitle = (body.title ?? '').trim();
const rawPiece = (body.piece ?? 'auto').trim();
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
// Title is NOT generated by an LLM at creation time anymore — that fired a
// second concurrent LLM request per task and churned gateway backend
// slots. Instead we set a cheap synchronous fallback now, and the agent
// upgrades it during the run by deriving from the Mission Brief goal
// (see Repository.updateMissionBriefSync). On-demand AI regeneration is
// available via POST /api/local/tasks/:id/regenerate-title.
const autoSelectedPiece = (rawPiece === 'auto' && opts.selectPiece)
? await opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id)
.catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
: rawPiece;
const taskTitle = userTitle || buildTitleFallback(body.body.trim());
const titleSource: 'auto' | 'user' = userTitle ? 'user' : 'auto';
const piece = autoSelectedPiece;
const profile = body.profile ?? 'auto';
const outputFormat = body.outputFormat ?? 'markdown';
const askPolicy = body.askPolicy ?? 'low';
const priority = body.priority ?? 'medium';
const scheduling = resolveJobScheduling({
role: profile,
pieceName: piece,
instruction: body.body.trim(),
});
// Per-task options (e.g. { mcpDisabled, skillsDisabled })
const rawOptions = req.body?.options;
const taskOptions: Record<string, unknown> =
rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
? rawOptions as Record<string, unknown>
: {};
// spaceId は閲覧できるスペースのみ受理する(見えない/存在しない ID は
// 個人スペース既定に倒す)。これで他スペースのワークスペースへの紛れ込みを防ぐ。
let resolvedSpaceId: string | null = null;
if (body.spaceId) {
const reqSpace = await repo.getSpace(body.spaceId, { viewer: req.user as Express.User | undefined });
if (reqSpace) {
resolvedSpaceId = reqSpace.id;
} else {
logger.warn(`[local-tasks] create: spaceId=${body.spaceId} not visible to user; falling back to personal space`);
}
}
// Tasks created inside a space are ALWAYS private: access is granted
// exclusively by space membership (the shared-space OR-branch in
// buildVisibilityWhere lets every member see all rows of their space).
// Forcing private here prevents an org/public selection from leaking the
// chat to non-members. Per-chat visibility is intentionally not selectable.
const effectiveVisibility = resolvedSpaceId ? 'private' : visibility;
const effectiveScopeOrgId = effectiveVisibility === 'org' ? visibilityScopeOrgId : null;
const task = await repo.createLocalTask({
title: taskTitle,
titleSource,
body: body.body.trim(),
pieceName: piece,
profile,
outputFormat,
askPolicy,
priority,
workspaceMode: body.workspaceMode,
spaceId: resolvedSpaceId,
ownerId: req.user?.id ?? noAuthOwner,
visibility: effectiveVisibility,
visibilityScopeOrgId: effectiveScopeOrgId,
browserSessionProfileId,
options: taskOptions,
});
// 実効スペースを解決し、mode に応じてワークスペースパスを決める。
// 既定は persistent個人/案件スペースの共有ツリー。ephemeral は使い捨て。
// resolveTaskFolderContext は副作用で個人スペース生成も担保する。
const mode = body.workspaceMode ?? 'persistent';
await repo.resolveTaskFolderContext(task.id, userFolderRoot);
const space = task.spaceId
? await repo.getSpace(task.spaceId)
: await repo.ensurePersonalSpace(task.ownerId ?? 'local');
const effectiveWorktreeDir = worktreeDir ?? './data/worktrees';
const workspacePath = space
? resolveTaskWorkspaceDir(mode, space, effectiveWorktreeDir, task.id)
: getLocalWorkspacePath(worktreeDir, task.id); // フォールバック(スペース解決不能時のみ)
ensureWorkspaceDirs(workspacePath);
// 計画5 (Phase B): persistent スペースタスクは実行ログを成果物ツリーから分離し、
// タスク単位の {worktreeDir}/space/{id}/runs/{taskId} に書く。ephemeral は
// runtime_dir=null のままで、worker/runPiece が ephemeral/{taskId}/logs に
// フォールバックする(後方互換)。
let runtimeDir: string | undefined;
if (mode === 'persistent' && space) {
runtimeDir = spaceRunsDir(effectiveWorktreeDir, space.id, task.id);
mkdirSync(runtimeDir, { recursive: true });
}
await repo.updateLocalTask(task.id, { workspacePath, ...(runtimeDir ? { runtimeDir } : {}) });
// Save attachments to input/. The resulting (sanitized) filenames are
// stored on the initial request comment so the UI can render download
// links, and reused below to tell the agent which files landed in input/.
// (コメント追加パス POST :id/comments と同じ挙動に揃える)
const savedFileNames = (body.attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
for (const att of body.attachments ?? []) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
}
await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request', savedFileNames);
const metadataBlock = [
'---',
`ui_profile: ${scheduling.role}`,
`ui_output_format: ${outputFormat}`,
`ui_ask_policy: ${askPolicy}`,
`ui_priority: ${priority}`,
'---',
].join('\n');
const attachmentBlock = savedFileNames.length > 0
? `添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}\n\n`
: '';
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${attachmentBlock}${metadataBlock}`.trim();
// Merge task options into job payload so the worker can read them at runtime.
const hasOptions = Object.keys(taskOptions).length > 0;
const job = await repo.createJob({
repo: localTaskRepoName(task.id),
issueNumber: task.id,
instruction,
pieceName: piece,
role: scheduling.role,
ownerId: task.ownerId,
visibility: task.visibility,
visibilityScopeOrgId: task.visibilityScopeOrgId,
browserSessionProfileId: task.browserSessionProfileId ?? null,
spaceId: task.spaceId ?? null,
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
});
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
if (rawPiece === 'auto') {
await repo.addAuditLog(job.id, 'piece_auto_selected', 'piece-classifier', {
selectedPiece: piece,
});
}
const created = await repo.getLocalTask(task.id);
res.status(201).json({ task: created, jobId: job.id });
} catch (err) {
logger.error(`Create local task API error: ${err}`);
res.status(500).json({ error: 'Failed to create local task' });
}
});
app.get('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ task });
} catch (err) {
logger.error(`Local task detail API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task' });
}
});
app.patch('/api/local/tasks/:taskId', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
if (req.body.title !== undefined) {
if (typeof req.body.title !== 'string') {
res.status(400).json({ error: 'title must be a string' }); return;
}
const trimmed = req.body.title.trim();
if (!trimmed) { res.status(400).json({ error: 'title must not be empty' }); return; }
if (trimmed.length > 200) { res.status(400).json({ error: 'title must be 200 characters or less' }); return; }
// Manual edit pins the title: the agent never auto-overwrites a user title.
updates.title = trimmed;
updates.titleSource = 'user';
}
if (req.body.visibility !== undefined) {
const v = req.body.visibility;
if (!['private', 'org', 'public'].includes(v)) {
res.status(400).json({ error: 'invalid visibility' }); return;
}
updates.visibility = v;
}
if (req.body.visibilityScopeOrgId !== undefined) {
updates.visibilityScopeOrgId = req.body.visibilityScopeOrgId ?? null;
}
if (updates.visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
const scopeId = updates.visibilityScopeOrgId ?? task!.visibilityScopeOrgId ?? null;
if (!scopeId || !orgIds.includes(scopeId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return;
}
updates.visibilityScopeOrgId = scopeId;
}
if (updates.visibility && updates.visibility !== 'org') {
updates.visibilityScopeOrgId = null;
}
await repo.updateLocalTask(taskId, updates);
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
await repo.updateJobsVisibilityForTask(taskId, {
visibility: refreshed.visibility ?? 'private',
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
});
}
res.json({ task: refreshed });
} catch (err) {
logger.error(`Patch local task API error: ${err}`);
res.status(500).json({ error: 'Failed to update task' });
}
});
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
await repo.deleteLocalTask(taskId, worktreeDir);
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('has an active job')) {
res.status(409).json({ error: 'Cannot delete task with running jobs' });
return;
}
logger.error(`Delete local task API error: ${err}`);
res.status(500).json({ error: 'Failed to delete local task' });
}
});
}

View File

@ -0,0 +1,83 @@
import express, { type Request, type Response } from 'express';
import type { Repository } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { canEditInSpace } from './visibility.js';
import type { LocalTasksApiOptions } from './local-tasks-api.js';
/**
* Shared dependencies threaded into each local-tasks route module.
*
* Built once in mountLocalTasksApi and passed to every registerLocalTask*Routes
* call so the route groups stay decoupled while sharing the same repo/config
* surface. `opts` carries the optional hooks (generateTitle / selectPiece /
* pieceExists / evaluatePrompt / getMaxUploadMb ) used by individual routes.
*/
export interface LocalTasksDeps {
repo: Repository;
worktreeDir?: string;
/** Resolved per-user folder root (opts.userFolderRoot ?? './data/users'). */
userFolderRoot: string;
sessRepo?: BrowserSessionRepo;
/**
* No-auth single-user mode owner ('local'), or undefined in auth mode so the
* real `req.user.id` is used. See LocalTasksApiOptions.authActive.
*/
noAuthOwner: string | undefined;
opts: LocalTasksApiOptions;
}
/**
* 3-valued write gate for shared-workspace tasks:
* 'allow' : owner/admin or editor+ space member write permitted
* 'view-only' : can view but lacks write permission (space viewer role) 403
* 'deny' : cannot even view (non-member / others' private) 404
*/
export type WriteGate = 'allow' | 'view-only' | 'deny';
/**
* Build the dynamic JSON body parser whose size limit is resolved per request
* from the current config (so config changes take effect without a restart).
* Clamped to [1, 1000] MB. Default 50.
*/
export function makeDynamicJson(getMaxUploadMb?: () => number) {
const resolveUploadLimit = (): string => {
const raw = getMaxUploadMb?.() ?? 50;
const mb = Number.isFinite(raw) ? Math.max(1, Math.min(1000, Math.floor(raw))) : 50;
return `${mb}mb`;
};
return (req: Request, res: Response, next: express.NextFunction) =>
express.json({ limit: resolveUploadLimit() })(req, res, next);
}
/**
* owner/admin
* editor
* owner-only DELETE 使
* checkTaskOwnership 使
*/
export async function resolveTaskWriteGate(
repo: Repository,
viewer: Express.User | undefined,
task: { ownerId?: string | null; spaceId?: string | null } | null,
): Promise<WriteGate> {
if (!task) return 'deny';
// no-authviewer 不在は従来通り素通りsynthetic local owner
if (!viewer) return 'allow';
if (viewer.role === 'admin') return 'allow';
if (task.ownerId && task.ownerId === viewer.id) return 'allow';
// スペースタスクならメンバーシップで判定。
if (task.spaceId) {
// getSpace は buildSpaceVisibilityWhere 経由。case スペースはメンバー
// editor/viewer どちらも)に可視なので、ここで space が取れる=閲覧可。
const space = await repo.getSpace(task.spaceId, { viewer });
if (space) {
const memberRole = repo.getSpaceMemberRole(task.spaceId, viewer.id);
if (canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return 'allow';
}
// 閲覧はできるが editor 未満viewer ロール / 根オーナーでもない)→ 403。
return 'view-only';
}
}
return 'deny';
}

View File

@ -0,0 +1,33 @@
/**
* delegate Task 3 SSE delegate
*/
import { describe, expect, it } from 'vitest';
import { formatDelegateSseFrame } from './local-tasks-stream-api.js';
describe('formatDelegateSseFrame', () => {
it('delegate_text を delegate_text_delta フレームに整形delegateRunId 保持)', () => {
const frame = formatDelegateSseFrame({ type: 'delegate_text', delegateRunId: 'r1', text: 'hello' });
expect(frame).toEqual({ type: 'delegate_text_delta', delegateRunId: 'r1', text: 'hello' });
});
it('delegate_lifecycle はフィールドを保持して転送', () => {
const frame = formatDelegateSseFrame({
type: 'delegate_lifecycle', delegateRunId: 'r1', parentRunId: null, depth: 1,
description: 'sub A', status: 'running',
});
expect(frame).toEqual({
type: 'delegate_lifecycle', delegateRunId: 'r1', parentRunId: null, depth: 1,
description: 'sub A', status: 'running',
});
});
it('delegate_tool は toolName/toolInput を保持して転送', () => {
const frame = formatDelegateSseFrame({
type: 'delegate_tool', delegateRunId: 'r1', toolName: 'WebFetch', toolInput: 'https://x',
});
expect(frame).toEqual({
type: 'delegate_tool', delegateRunId: 'r1', toolName: 'WebFetch', toolInput: 'https://x',
});
});
it('delegate 以外は nullこのフォーマッタの対象外', () => {
expect(formatDelegateSseFrame({ type: 'text', text: 'x' })).toBeNull();
});
});

View File

@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import type { Repository } from '../db/repository.js';
import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js';
import { jobEventBus } from './job-events.js';
import type { LocalTasksDeps } from './local-tasks-shared.js';
// Functional tests for GET /api/local/tasks/:taskId/stream (APIC-016, gap: NONE).
// Covers the visibility gate (non-viewer must not leak), the 204 no-running-job
// path, the content-type + SSE-frame mapping for tool-call/text events, and the
// `done` event that ends the stream. The stream is event-driven via jobEventBus
// (no initial frame is written on open), so each test pumps an event through the
// bus and asserts the resulting `data:` frame.
const RUNNING_JOB = { id: 'job-1', status: 'running' as const };
// getLocalTask honors the visibility model: a viewer who cannot see the task
// receives `null`, which the route maps to 404 (no task data leaked). We model
// that by having the fake repo return null when the wrong viewer asks.
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
getLocalTask: vi.fn().mockResolvedValue({
id: 1,
ownerId: 'user-1',
visibility: 'private',
latestJob: RUNNING_JOB,
}),
...overrides,
} as unknown as Repository;
}
function makeUser(overrides: Partial<Express.User> = {}): Express.User {
return {
id: 'user-1',
email: 'u@example.com',
name: 'User One',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
...overrides,
};
}
function makeApp(repo: Repository, user?: Express.User): express.Application {
const app = express();
if (user) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
}
const deps: LocalTasksDeps = {
repo,
userFolderRoot: './data/users',
noAuthOwner: undefined,
opts: { repo },
};
registerLocalTaskStreamRoutes(app, deps);
return app;
}
describe('GET /api/local/tasks/:taskId/stream — visibility gate', () => {
it('returns 404 (no leak) when the viewer cannot see the task', async () => {
// getLocalTask returns null for a non-viewer (visibility model applied inside repo).
const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue(null) });
const app = makeApp(repo, makeUser({ id: 'intruder' }));
const res = await request(app).get('/api/local/tasks/1/stream');
expect(res.status).toBe(404);
// No task fields are echoed — only a generic error.
expect(res.body).toEqual({ error: 'task not found' });
expect(res.text).not.toContain('user-1');
});
it('returns 400 for a malformed taskId', async () => {
const app = makeApp(makeRepo(), makeUser());
const res = await request(app).get('/api/local/tasks/not-a-number/stream');
expect(res.status).toBe(400);
});
it('returns 204 when there is no running/dispatching job', async () => {
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1, ownerId: 'user-1', visibility: 'private',
latestJob: { id: 'job-1', status: 'succeeded' },
}),
});
const app = makeApp(repo, makeUser());
const res = await request(app).get('/api/local/tasks/1/stream');
expect(res.status).toBe(204);
});
});
describe('GET /api/local/tasks/:taskId/stream — SSE event mapping', () => {
// Streaming-with-supertest note: supertest buffers the whole response and only
// resolves once the connection closes. The stream stays open until a `done`
// event (which calls res.end()) or the client aborts. So each test schedules
// events on the bus shortly after the request opens, ENDING with a `done`
// event so the request terminates deterministically (no hanging connection).
// The 15s heartbeat never fires within these sub-second tests.
afterEach(() => {
// Defensively drop any listeners a test left behind.
jobEventBus.removeAllListeners(`job:${RUNNING_JOB.id}`);
});
it('opens text/event-stream and maps a tool-call delta into a data: frame', async () => {
const app = makeApp(makeRepo(), makeUser());
// Pump events once a listener is attached (the route subscribes during the
// async handler). Poll for the listener, then emit a tool_use_delta + done.
const pump = setInterval(() => {
if (jobEventBus.hasListeners(RUNNING_JOB.id)) {
clearInterval(pump);
jobEventBus.emitJob(RUNNING_JOB.id, {
type: 'tool_use_delta', callId: 'c1', name: 'Read', chunk: '{"file_path":"a.txt"}',
});
// The tool buffer flushes after 50ms; emit `done` a bit later so the
// flush lands first, then the stream ends.
setTimeout(() => {
jobEventBus.emitJob(RUNNING_JOB.id, { type: 'done' });
}, 120);
}
}, 5);
const res = await request(app).get('/api/local/tasks/1/stream');
clearInterval(pump);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/event-stream');
expect(res.headers['cache-control']).toContain('no-store');
// The tool-call delta is reconstructed into an SSE data: frame.
expect(res.text).toContain('data: ');
expect(res.text).toContain('"type":"tool_use_delta"');
expect(res.text).toContain('"name":"Read"');
expect(res.text).toContain('"callId":"c1"');
// The terminal done frame is present.
expect(res.text).toContain('"type":"done"');
});
it('maps a text event into a text_delta data: frame', async () => {
const app = makeApp(makeRepo(), makeUser());
const pump = setInterval(() => {
if (jobEventBus.hasListeners(RUNNING_JOB.id)) {
clearInterval(pump);
jobEventBus.emitJob(RUNNING_JOB.id, { type: 'text', text: 'hello world' });
setTimeout(() => jobEventBus.emitJob(RUNNING_JOB.id, { type: 'done' }), 120);
}
}, 5);
const res = await request(app).get('/api/local/tasks/1/stream');
clearInterval(pump);
expect(res.status).toBe(200);
expect(res.text).toContain('"type":"text_delta"');
expect(res.text).toContain('hello world');
expect(res.text).toContain('"type":"done"');
});
it('cleans up its bus listener after the stream ends', async () => {
const app = makeApp(makeRepo(), makeUser());
const pump = setInterval(() => {
if (jobEventBus.hasListeners(RUNNING_JOB.id)) {
clearInterval(pump);
jobEventBus.emitJob(RUNNING_JOB.id, { type: 'done' });
}
}, 5);
await request(app).get('/api/local/tasks/1/stream');
clearInterval(pump);
// After `done` → res.end() → cleanup(), the listener is removed.
expect(jobEventBus.hasListeners(RUNNING_JOB.id)).toBe(false);
});
});

View File

@ -0,0 +1,178 @@
import { type Application, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js';
import { type LocalTasksDeps } from './local-tasks-shared.js';
/**
* delegate SSE JSON
* delegate_text delegate_text_delta
* null
*/
export function formatDelegateSseFrame(
event: JobStreamEvent,
): Record<string, unknown> | null {
switch (event.type) {
case 'delegate_text':
return { type: 'delegate_text_delta', delegateRunId: event.delegateRunId, text: event.text };
case 'delegate_lifecycle':
return {
type: 'delegate_lifecycle',
delegateRunId: event.delegateRunId,
parentRunId: event.parentRunId ?? null,
depth: event.depth ?? 0,
description: event.description ?? '',
status: event.status ?? 'running',
};
case 'delegate_tool':
return {
type: 'delegate_tool',
delegateRunId: event.delegateRunId,
toolName: event.toolName ?? '',
toolInput: event.toolInput ?? '',
};
default:
return null;
}
}
/**
* Server-Sent Events stream of real-time job events (text deltas, tool-call
* deltas, prompt progress) for a task's currently-running job.
*/
export function registerLocalTaskStreamRoutes(app: Application, deps: LocalTasksDeps): void {
const { repo } = deps;
// ── SSE stream: real-time job events ──────────────────────────────────────
app.get('/api/local/tasks/:taskId/stream', async (req: Request, res: Response) => {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'invalid taskId' }); return; }
try {
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : {});
if (!task) { res.status(404).json({ error: 'task not found' }); return; }
const runningJob = task.latestJob;
if (!runningJob || (runningJob.status !== 'running' && runningJob.status !== 'dispatching')) {
res.status(204).end();
return;
}
const jobId = runningJob.id;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Text delta batching (50ms flush)
let textBuf = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const TEXT_FLUSH_MS = 50;
// Tool-call argument delta batching, keyed by callId (50ms flush).
const toolBuf = new Map<string, { name: string; chunk: string }>();
let toolFlushTimer: ReturnType<typeof setTimeout> | null = null;
// delegate live: per-run text batching (50ms flush)。
const delegateTextBuf = new Map<string, string>();
let delegateFlushTimer: ReturnType<typeof setTimeout> | null = null;
const flushDelegateText = () => {
for (const [delegateRunId, text] of delegateTextBuf) {
if (res.writableEnded) break;
res.write(`data: ${JSON.stringify({ type: 'delegate_text_delta', delegateRunId, text })}\n\n`);
}
delegateTextBuf.clear();
delegateFlushTimer = null;
};
const flushText = () => {
if (textBuf) {
const data = JSON.stringify({ type: 'text_delta', text: textBuf });
res.write(`data: ${data}\n\n`);
textBuf = '';
}
flushTimer = null;
};
const flushToolDeltas = () => {
for (const [callId, { name, chunk }] of toolBuf) {
if (res.writableEnded) break;
res.write(`data: ${JSON.stringify({ type: 'tool_use_delta', callId, name, chunk })}\n\n`);
}
toolBuf.clear();
toolFlushTimer = null;
};
const handler = (event: JobStreamEvent) => {
if (res.writableEnded) return;
if (event.type === 'text') {
textBuf += event.text ?? '';
if (!flushTimer) flushTimer = setTimeout(flushText, TEXT_FLUSH_MS);
return;
}
if (event.type === 'tool_use_delta') {
const callId = event.callId ?? '';
// chunk is a full snapshot of args-so-far; keep the LATEST per
// callId (replace, not append) so each flush sends the newest
// complete prefix. Coalesces many snapshots into one per 50ms.
toolBuf.set(callId, {
name: event.name ?? toolBuf.get(callId)?.name ?? '',
chunk: event.chunk ?? '',
});
if (!toolFlushTimer) toolFlushTimer = setTimeout(flushToolDeltas, TEXT_FLUSH_MS);
return;
}
if (event.type === 'delegate_text') {
const id = event.delegateRunId ?? '';
delegateTextBuf.set(id, (delegateTextBuf.get(id) ?? '') + (event.text ?? ''));
if (!delegateFlushTimer) delegateFlushTimer = setTimeout(flushDelegateText, TEXT_FLUSH_MS);
return;
}
if (event.type === 'delegate_lifecycle' || event.type === 'delegate_tool') {
// この run の保留テキストを先に出してから lifecycle/tool を送る(順序保証)。
if (delegateTextBuf.size) flushDelegateText();
const frame = formatDelegateSseFrame(event);
if (frame) res.write(`data: ${JSON.stringify(frame)}\n\n`);
return;
}
// Flush pending text + tool deltas before non-streaming events
if (textBuf) flushText();
if (toolBuf.size) flushToolDeltas();
if (delegateTextBuf.size) flushDelegateText();
if (event.type === 'prompt_progress') {
const effective = (event.processed ?? 0) - (event.cache ?? 0);
const effectiveTotal = (event.total ?? 0) - (event.cache ?? 0);
const percent = effectiveTotal > 0 ? Math.round(effective / effectiveTotal * 100) : 0;
res.write(`data: ${JSON.stringify({ type: 'prompt_progress', percent, processed: event.processed, total: event.total, cache: event.cache, timeMs: event.timeMs })}\n\n`);
} else if (event.type === 'done') {
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
cleanup();
res.end();
} else {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
};
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': heartbeat\n\n');
}, 15_000);
const cleanup = () => {
jobEventBus.offJob(jobId, handler);
clearInterval(heartbeat);
if (flushTimer) { clearTimeout(flushTimer); flushText(); }
if (toolFlushTimer) { clearTimeout(toolFlushTimer); flushToolDeltas(); }
if (delegateFlushTimer) { clearTimeout(delegateFlushTimer); flushDelegateText(); }
};
jobEventBus.onJob(jobId, handler);
req.on('close', cleanup);
} catch (err) {
logger.error(`Local task stream API error: ${err}`);
if (!res.headersSent) res.status(500).json({ error: 'stream failed' });
}
});
}

View File

@ -0,0 +1,72 @@
import express, { type Application, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
import { type LocalTasksDeps, resolveTaskWriteGate } from './local-tasks-shared.js';
/**
* Tool-request mechanism: list a task's recorded tool requests, and
* approve/deny a pending one (granting the tool for the rest of the task).
*/
export function registerLocalTaskToolRequestRoutes(app: Application, deps: LocalTasksDeps): void {
const { repo } = deps;
// Tool-request mechanism: list the tool requests recorded for a task
// (RequestTool declarations + passively-captured blocked calls).
app.get('/api/local/tasks/:taskId/tool-requests', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ toolRequests: repo.listToolRequestsByTask(String(taskId)) });
} catch (err) {
logger.error(`Tool requests list API error: ${err}`);
res.status(500).json({ error: 'Failed to list tool requests' });
}
});
// Tool-request mechanism: approve (grant for this task + resume) or deny a
// pending tool request. Requires task write permission (owner / admin /
// space editor). Approving grants the tool for the rest of THIS task; the
// permanent fix (adding it to the piece) is a separate one-click action in
// the piece editor's aggregation view.
app.post('/api/local/tasks/:taskId/tool-requests/:reqId/decide', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const reqId = String(req.params.reqId);
const decision = (req.body as { decision?: unknown })?.decision;
if (decision !== 'approve' && decision !== 'deny') {
res.status(400).json({ error: 'decision must be "approve" or "deny"' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
const gate = await resolveTaskWriteGate(repo, viewer, task ? { ownerId: task.ownerId, spaceId: task.spaceId } : null);
if (gate !== 'allow') {
res.status(gate === 'view-only' ? 403 : 404)
.json({ error: gate === 'view-only' ? 'No permission to decide tool requests' : 'Task not found' });
return;
}
const tr = repo.getToolRequest(reqId);
if (!tr || tr.taskId !== String(taskId)) { res.status(404).json({ error: 'Tool request not found' }); return; }
if (tr.status !== 'pending') { res.status(409).json({ error: `Already decided (${tr.status})` }); return; }
const decidedBy = viewer?.id ?? null;
if (decision === 'approve') {
repo.addGrantedTool(String(taskId), tr.toolName);
repo.decideToolRequest(reqId, { status: 'approved', grantScope: 'task', decidedBy });
} else {
repo.decideToolRequest(reqId, { status: 'denied', decidedBy });
}
// Resume the parked job so the agent continues (with or without the tool).
const resumed = tr.jobId ? repo.resumeToolRequestJob(tr.jobId) : 0;
res.json({ ok: true, decision, toolName: tr.toolName, resumed: resumed > 0 });
} catch (err) {
logger.error(`Tool request decide API error: ${err}`);
res.status(500).json({ error: 'Failed to decide tool request' });
}
});
}

209
src/bridge/mcp-subsystem.ts Normal file
View File

@ -0,0 +1,209 @@
import express from 'express';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { loadConfig } from '../config.js';
import { requireAuth, requireAdmin } from './auth.js';
import { canEditInSpace } from './visibility.js';
import { isKeyConfigured } from '../mcp/crypto.js';
import { createRegistry } from '../mcp/registry.js';
import { createTokenManager } from '../mcp/token-manager.js';
import { createToolCache } from '../mcp/tool-cache.js';
import { createAggregator } from '../mcp/aggregator.js';
import { createMcpClient } from '../mcp/client-factory.js';
import { executeMcpCall } from '../mcp/tool-executor.js';
import { refreshAccessToken } from '../mcp/discovery.js';
import { setMcpAggregator } from '../engine/tools/index.js';
import { setMcpToolLookup } from '../engine/tools/docs.js';
import { setCalendarRepo } from '../engine/tools/calendar.js';
import { createMcpOauthRouter } from '../mcp/oauth-routes.js';
import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js';
import { mergeMcpConfig } from '../mcp/config.js';
import type { McpCatalogDeps } from './tools-api.js';
export interface McpSubsystemDeps {
repo: Repository;
authActive: boolean;
workerManager?: import('../worker-manager.js').WorkerManager;
/** OAuth callback base URL (derived from auth config by the caller). */
callbackBaseUrl: string;
}
/**
* Wire the MCP subsystem (registry / token manager / tool cache / aggregator)
* and mount its OAuth + admin/connections/user-servers routers. Gated on
* MCP_ENCRYPTION_KEY: when the key is absent the subsystem is skipped and the
* function returns null.
*
* Returns the McpCatalogDeps that /api/tools uses to surface per-user MCP
* tools in the Piece allowed_tools editor (null when MCP is disabled).
*
* Extracted verbatim from createCoreServer (server.ts) to keep that bootstrap
* function focused; see server-subsystems.test.ts for the gate characterization.
*/
export function setupMcpSubsystem(app: express.Application, deps: McpSubsystemDeps): McpCatalogDeps | null {
const { repo, authActive, workerManager, callbackBaseUrl } = deps;
// Surfaces per-user MCP servers into /api/tools so the Piece allowed_tools
// editor can include them. Populated when the subsystem initialises;
// remains null otherwise.
let mcpCatalogDeps: McpCatalogDeps | null = null;
if (isKeyConfigured()) {
const mcpConfig = mergeMcpConfig(loadConfig().mcp);
const mcpRegistry = createRegistry(repo.getDb());
const mcpTokenManager = createTokenManager(repo.getDb(), {
doRefresh: async (serverId: string, refreshToken: string) => {
const server = mcpRegistry.getDecrypted(serverId);
if (!server || !server.tokenEndpoint) {
throw new Error(`server or token endpoint missing for ${serverId}`);
}
return refreshAccessToken({
tokenEndpoint: server.tokenEndpoint,
clientId: server.oauthClientId,
clientSecret: server.oauthClientSecret,
refreshToken,
});
},
});
const mcpToolCache = createToolCache(repo.getDb(), mcpConfig.toolCacheTtlSeconds);
const mcpAggregator = createAggregator({
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
executeCall: async (args) => {
const server = mcpRegistry.getDecrypted(args.serverId);
if (!server) return { output: `未登録の MCP サーバー: ${args.serverId}`, isError: true };
const { client, close } = await createMcpClient(server, args.accessToken, {
callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
});
try {
return await executeMcpCall({
client,
serverId: args.serverId,
toolName: args.toolName,
input: args.input,
ctx: args.ctx,
});
} finally {
await close();
}
},
});
setMcpAggregator(mcpAggregator);
setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName));
setCalendarRepo(repo);
workerManager?.setMcpDeps({ tokenManager: mcpTokenManager });
// Expose MCP enumeration to /api/tools so the Piece allowed_tools editor
// can list per-user MCP tools alongside builtin ones. Methods on the
// registry/tokenManager/toolCache surfaces are already DB-backed and
// safe to call per-request.
mcpCatalogDeps = {
registry: { listEnabledForUser: (uid) => mcpRegistry.listEnabledForUser(uid) },
tokenManager: { hasToken: (uid, sid) => mcpTokenManager.hasToken(uid, sid) },
toolCache: { getAllForServers: (ids) => mcpToolCache.getAllForServers(ids) },
};
app.use(
'/auth/mcp',
createMcpOauthRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
pendingTtlMinutes: mcpConfig.oauthPendingTtlMinutes,
getCallbackBaseUrl: () => callbackBaseUrl,
getAuthenticatedUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
resumeWaitingJobs: (uid, sid) => {
repo.resumeMcpWaitingJobs(uid, sid);
},
listToolsAfterAuth: async (serverId: string, accessToken: string) => {
const server = mcpRegistry.getDecrypted(serverId);
if (!server) return;
const { client, close } = await createMcpClient(server, accessToken, {
callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
});
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
mcpToolCache.replaceForServer(serverId, list.tools);
logger.info(
`[mcp] auto list_tools after OAuth server=${serverId} count=${list.tools.length}`,
);
} finally {
await close();
}
},
}),
);
app.use('/api/mcp/servers', express.json(), createMcpAdminRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
}));
app.use('/api/mcp/connections', express.json(), createMcpUserRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
}));
app.use('/api/mcp/user-servers', express.json(), createMcpUserServersRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
// No-auth single-user mode: passport is not mounted, so req.user is
// never populated. Fall back to the synthetic `local` admin (the same
// owner the SSH user router and space API use) so per-user / per-space
// MCP server management stays usable; otherwise getUserId is null and
// every register/list 401s, making the per-space MCP panel dead in
// no-auth deployments. No-op when auth is active (req.user wins).
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? (authActive ? null : 'local'),
// Mirror the no-auth fallback: when auth is off and passport hasn't
// populated a viewer, authorize the space against the synthetic `local`
// admin so getSpace doesn't reject the (null-owner) local space.
getSpace: (spaceId, viewer) =>
repo.getSpace(spaceId, {
viewer: viewer ?? (authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User)),
}),
// Space-as-Principal P2: edit-rights check so space members can share the
// management of space-owned api_key MCP servers. Mirrors the no-auth
// synthetic local admin used by getUserId/getSpace above.
canEditSpace: async (spaceId, req) => {
const viewer =
(req.user as Express.User | undefined) ??
(authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User));
if (!viewer) return false;
const space = await repo.getSpace(spaceId, { viewer });
if (!space) return false;
const role = repo.getSpaceMemberRole(spaceId, viewer.id);
return canEditInSpace(viewer, { ownerId: space.ownerId }, role);
},
insecureLocalTestMode: false,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
}));
logger.info('[mcp] subsystem initialised');
} else {
logger.warn('[mcp] MCP_ENCRYPTION_KEY not configured — MCP features disabled');
}
return mcpCatalogDeps;
}

View File

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mountMetaApi } from './meta-api.js';
function makeApp(repos: string[], configured?: string[]) {
const app = express();
mountMetaApi(app, { repo: { getDistinctRepos: () => repos }, configuredRepos: configured });
return app;
}
describe('meta-api', () => {
it('GET /api/repos merges configured + job repos, deduped and sorted', async () => {
const res = await request(makeApp(['b', 'a', 'b'], ['c', 'a', ''])).get('/api/repos');
expect(res.status).toBe(200);
expect(res.body.repos).toEqual(['a', 'b', 'c']);
});
it('GET /api/repos returns 500 when the repo throws', async () => {
const app = express();
mountMetaApi(app, { repo: { getDistinctRepos: () => { throw new Error('db down'); } } });
const res = await request(app).get('/api/repos');
expect(res.status).toBe(500);
expect(res.body.error).toBe('Failed to fetch repos');
});
it('GET /api/version returns a non-empty version string', async () => {
const res = await request(makeApp([])).get('/api/version');
expect(res.status).toBe(200);
expect(typeof res.body.version).toBe('string');
expect(res.body.version.length).toBeGreaterThan(0);
});
});

53
src/bridge/meta-api.ts Normal file
View File

@ -0,0 +1,53 @@
import express, { Request, Response } from 'express';
/**
* meta-api.ts small, dependency-light "meta/info" endpoints extracted from
* createCoreServer (server.ts) to keep that bootstrap function focused on
* wiring. These are unique, order-independent read endpoints:
* GET /api/version build/version string
* GET /api/repos distinct repos from jobs configured repos
*
* Auth note: the `requireAuth` guard for /api/repos is registered separately
* in server.ts's auth block (`app.use('/api/repos', requireAuth)`); /api/version
* is intentionally unauthenticated. Mounting position is preserved at the call
* site so that ordering relative to that guard is unchanged.
*/
export interface MetaApiDeps {
/** Repository (only getDistinctRepos is used). */
repo: { getDistinctRepos(): string[] };
/** Repos declared in config.yaml, merged with the ones seen in jobs. */
configuredRepos?: string[];
}
export function mountMetaApi(app: express.Application, deps: MetaApiDeps): void {
const { repo, configuredRepos } = deps;
// Version endpoint
app.get('/api/version', async (_req: Request, res: Response) => {
let version = 'dev';
try {
const mod = await import('../generated/version.js');
version = mod.APP_VERSION;
} catch {
try {
const { execSync } = await import('child_process');
version = execSync("TZ=UTC git log -1 --format=%cd --date=format:'%Y%m%d.%H%M%S'", { encoding: 'utf-8' }).trim();
} catch {
// keep 'dev'
}
}
res.json({ version });
});
app.get('/api/repos', (_req: Request, res: Response) => {
try {
const reposFromJobs = repo.getDistinctRepos();
const reposFromConfig = (configuredRepos ?? []).filter(Boolean);
const repos = Array.from(new Set([...reposFromConfig, ...reposFromJobs])).sort();
res.json({ repos });
} catch {
res.status(500).json({ error: 'Failed to fetch repos' });
}
});
}

View File

@ -0,0 +1,90 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import ExcelJS from 'exceljs';
import {
getSpreadsheetPreview,
getPresentationPreview,
isSpreadsheetFile,
isPresentationFile,
isOfficePreviewFile,
SofficeUnavailableError,
} from './office-preview.js';
describe('office-preview ファイル判定', () => {
it('Excel は xlsx/xlsm のみ (csv/xls は対象外)', () => {
expect(isSpreadsheetFile('a.xlsx')).toBe(true);
expect(isSpreadsheetFile('a.XLSM')).toBe(true);
expect(isSpreadsheetFile('a.csv')).toBe(false);
expect(isSpreadsheetFile('a.xls')).toBe(false);
});
it('PowerPoint は pptx/ppt', () => {
expect(isPresentationFile('deck.pptx')).toBe(true);
expect(isPresentationFile('deck.PPT')).toBe(true);
expect(isPresentationFile('deck.key')).toBe(false);
});
it('isOfficePreviewFile は両方を拾う', () => {
expect(isOfficePreviewFile('a.xlsx')).toBe(true);
expect(isOfficePreviewFile('a.pptx')).toBe(true);
expect(isOfficePreviewFile('a.txt')).toBe(false);
});
});
describe('getSpreadsheetPreview', () => {
let dir: string;
let xlsxPath: string;
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'office-preview-test-'));
xlsxPath = join(dir, 'sample.xlsx');
const wb = new ExcelJS.Workbook();
const ws1 = wb.addWorksheet('データ');
ws1.addRow(['名前', '年齢', '登録日']);
ws1.addRow(['田中', 30, new Date('2026-01-15T00:00:00Z')]);
ws1.addRow(['佐藤', 25, '']);
const ws2 = wb.addWorksheet('集計');
ws2.addRow(['合計']);
ws2.getCell('A2').value = { formula: 'SUM(1,2)', result: 3 } as ExcelJS.CellValue;
await wb.xlsx.writeFile(xlsxPath);
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it('全シートをセル文字列の二次元配列で返す', async () => {
const preview = await getSpreadsheetPreview(xlsxPath);
expect(preview.kind).toBe('spreadsheet');
expect(preview.sheets.map((s) => s.name)).toEqual(['データ', '集計']);
const first = preview.sheets[0];
expect(first.rows[0]).toEqual(['名前', '年齢', '登録日']);
expect(first.rows[1][0]).toBe('田中');
expect(first.rows[1][1]).toBe('30');
});
it('数式セルは計算結果を文字列で返す', async () => {
const preview = await getSpreadsheetPreview(xlsxPath);
const summary = preview.sheets.find((s) => s.name === '集計')!;
expect(summary.rows[1][0]).toBe('3');
});
});
describe('getPresentationPreview', () => {
it('soffice が無い環境では SofficeUnavailableError を投げる', async () => {
const dir = mkdtempSync(join(tmpdir(), 'office-preview-pptx-'));
const pptxPath = join(dir, 'deck.pptx');
writeFileSync(pptxPath, 'dummy'); // soffice は起動前に ENOENT になるので中身は不問
const prev = process.env.SOFFICE_BIN;
// SOFFICE_BIN は呼び出し時に解決されるので、存在しないバイナリを指せば soffice の
// 導入有無に関係なく ENOENT → SofficeUnavailableError を確定的に再現できる。
process.env.SOFFICE_BIN = join(dir, 'no-such-soffice-binary');
try {
await expect(getPresentationPreview(pptxPath)).rejects.toBeInstanceOf(SofficeUnavailableError);
} finally {
if (prev === undefined) delete process.env.SOFFICE_BIN;
else process.env.SOFFICE_BIN = prev;
rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@ -0,0 +1,419 @@
// office-preview.ts — Excel / PowerPoint ファイルのプレビュー用データを生成する共有ヘルパー。
//
// タスク用 (local-files-api.ts) とスペース用 (space-api.ts) の office-preview
// エンドポイントが、認証・パス封じ込めを済ませた「絶対パス」を渡して呼ぶ。
//
// - Excel (.xlsx/.xlsm): exceljs で各シートをセル文字列の二次元配列に変換 (Node 内で完結)。
// - PowerPoint (.pptx/.ppt): LibreOffice (soffice) で PDF 化 → PyMuPDF(fitz) で各ページを
// PNG 化 → base64 data URL で返す。fitz は runtime に pre-baked 済み (PdfToImages と同じ)。
// soffice だけ Dockerfile で同梱する。変換結果は mtime+size をキーに tmpdir へキャッシュ。
//
// soffice 未導入環境 (ローカル dev など) では SofficeUnavailableError を投げる。呼出側は
// これを 503 + コードに変換し、UI は「変換エンジン未導入」を表示してダウンロードへ誘導する。
import { spawn } from 'child_process';
import { createHash } from 'crypto';
import { mkdirSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync, rmSync } from 'fs';
import { join, parse as parsePath } from 'path';
import { tmpdir } from 'os';
import type { Response } from 'express';
import ExcelJS from 'exceljs';
import { logger } from '../logger.js';
// ── 上限 (プレビューなので欲張らない) ────────────────────────────
const MAX_SHEETS = 30;
const MAX_ROWS = 500;
const MAX_COLS = 50;
const MAX_SLIDES = 100;
// 呼び出し時に解決する (テストで差し替え可能にするため module 定数にしない)。
function sofficeBin(): string {
return process.env.SOFFICE_BIN || 'soffice';
}
const CACHE_ROOT = join(tmpdir(), 'maestro-office-preview');
const CONVERT_TIMEOUT_MS = 120_000;
const RENDER_DPI = 110; // スライド画像の解像度 (見やすさ vs データ量のバランス)
export interface SpreadsheetSheet {
name: string;
rows: string[][];
/** シートの総行数 (上限で切る前の値) */
rowCount: number;
/** シートの総列数 (上限で切る前の値) */
colCount: number;
/** 行 or 列が上限で切られたか */
truncated: boolean;
}
export interface SpreadsheetPreview {
kind: 'spreadsheet';
sheets: SpreadsheetSheet[];
/** シート数 or いずれかのシートが切られたか */
truncated: boolean;
}
export interface PresentationSlide {
index: number;
/** data:image/png;base64,... */
dataUrl: string;
}
export interface PresentationPreview {
kind: 'presentation';
slides: PresentationSlide[];
slideCount: number;
/** スライド数が上限を超えて切られたか */
truncated: boolean;
}
/** soffice が見つからない / 起動できないときに投げる。呼出側で 503 化する。 */
export class SofficeUnavailableError extends Error {
constructor(message = 'LibreOffice (soffice) is not available') {
super(message);
this.name = 'SofficeUnavailableError';
}
}
export function isSpreadsheetFile(name: string): boolean {
return /\.(xlsx|xlsm)$/i.test(name);
}
export function isPresentationFile(name: string): boolean {
return /\.(pptx|ppt)$/i.test(name);
}
export function isOfficePreviewFile(name: string): boolean {
return isSpreadsheetFile(name) || isPresentationFile(name);
}
// ── HTTP レスポンスヘルパー (タスク用 / スペース用 エンドポイントで共用) ───────
/**
* Excel / PPTX JSON res office
* 415 (soffice ) catch
* handleOfficePreviewError
*/
export async function sendOfficePreview(res: Response, absPath: string, name: string): Promise<void> {
if (isSpreadsheetFile(name)) {
res.json(await getSpreadsheetPreview(absPath));
return;
}
if (isPresentationFile(name)) {
res.json(await getPresentationPreview(absPath));
return;
}
res.status(415).json({ error: 'not an office-previewable file' });
}
/** office-preview エンドポイント共通の例外 → HTTP 変換。 */
export function handleOfficePreviewError(res: Response, err: unknown): void {
if (err instanceof SofficeUnavailableError) {
res.status(503).json({ error: 'converter_unavailable', message: err.message });
return;
}
const e = err as NodeJS.ErrnoException;
if (e?.code === 'ENOENT') {
res.status(404).json({ error: 'not found' });
return;
}
// パス封じ込めエラー (ensurePathWithin) のメッセージ規約に合わせる。
if (typeof e?.message === 'string' && /escape/i.test(e.message)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`[office-preview] ${e?.message ?? err}`);
res.status(500).json({ error: 'Failed to render office preview' });
}
// ── Excel ────────────────────────────────────────────────────
export async function getSpreadsheetPreview(absPath: string): Promise<SpreadsheetPreview> {
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(absPath);
let truncated = false;
const allSheets = wb.worksheets;
if (allSheets.length > MAX_SHEETS) truncated = true;
const sheets: SpreadsheetSheet[] = [];
for (const ws of allSheets.slice(0, MAX_SHEETS)) {
const totalRows = ws.rowCount;
const totalCols = ws.columnCount;
const colLimit = Math.min(totalCols, MAX_COLS);
const rowLimit = Math.min(totalRows, MAX_ROWS);
const sheetTruncated = totalRows > MAX_ROWS || totalCols > MAX_COLS;
if (sheetTruncated) truncated = true;
const rows: string[][] = [];
for (let r = 1; r <= rowLimit; r++) {
const row = ws.getRow(r);
const cells: string[] = [];
for (let c = 1; c <= colLimit; c++) {
cells.push(cellText(row.getCell(c)));
}
rows.push(cells);
}
sheets.push({ name: ws.name, rows, rowCount: totalRows, colCount: totalCols, truncated: sheetTruncated });
}
return { kind: 'spreadsheet', sheets, truncated };
}
/** exceljs のセルを表示用の文字列に変換する (数式は結果、リッチテキストは平文、日付は ISO 風)。 */
function cellText(cell: ExcelJS.Cell): string {
const v = cell.value;
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v instanceof Date) return v.toISOString().slice(0, 19).replace('T', ' ');
if (typeof v === 'object') {
const obj = v as unknown as Record<string, unknown>;
// 数式: { formula, result }
if ('result' in obj && obj.result != null) {
const r = obj.result;
if (r instanceof Date) return r.toISOString().slice(0, 19).replace('T', ' ');
if (typeof r === 'object') return cell.text ?? '';
return String(r);
}
// リッチテキスト: { richText: [{text}] }
if (Array.isArray(obj.richText)) {
return (obj.richText as { text?: string }[]).map((t) => t.text ?? '').join('');
}
// ハイパーリンク: { text, hyperlink }
if ('text' in obj && typeof obj.text === 'string') return obj.text;
// エラー: { error }
if ('error' in obj) return String(obj.error);
}
// 最終手段: exceljs の整形済みテキスト
return cell.text ?? '';
}
// ── PowerPoint ───────────────────────────────────────────────
export async function getPresentationPreview(absPath: string): Promise<PresentationPreview> {
const st = statSync(absPath);
const key = createHash('sha1').update(`${absPath}:${st.mtimeMs}:${st.size}`).digest('hex');
const cacheDir = join(CACHE_ROOT, key);
let pngFiles = readCachedSlides(cacheDir);
if (!pngFiles) {
pngFiles = await convertPptxToPngs(absPath, cacheDir);
}
const limited = pngFiles.slice(0, MAX_SLIDES);
const slides: PresentationSlide[] = limited.map((file, i) => ({
index: i + 1,
dataUrl: `data:image/png;base64,${readFileSync(file).toString('base64')}`,
}));
return {
kind: 'presentation',
slides,
slideCount: pngFiles.length,
truncated: pngFiles.length > MAX_SLIDES,
};
}
const CACHE_MANIFEST = '.ok';
/** キャッシュ済みなら slide-*.png を番号順で返す。未キャッシュなら null。 */
function readCachedSlides(cacheDir: string): string[] | null {
if (!existsSync(join(cacheDir, CACHE_MANIFEST))) return null;
return listSlidePngs(cacheDir);
}
function listSlidePngs(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => /^slide-\d+\.png$/.test(f))
.sort((a, b) => slideNum(a) - slideNum(b))
.map((f) => join(dir, f));
}
function slideNum(file: string): number {
const m = file.match(/slide-(\d+)\.png$/);
return m ? parseInt(m[1], 10) : 0;
}
async function convertPptxToPngs(absPath: string, cacheDir: string): Promise<string[]> {
// キャッシュディレクトリを作り直す (古い slide が残らないように)。
rmSync(cacheDir, { recursive: true, force: true });
mkdirSync(cacheDir, { recursive: true });
// soffice はユーザープロファイルを必要とする。並行変換でプロファイルロックが
// 衝突しないよう、変換ごとに専用プロファイルを使う。
const profileDir = join(cacheDir, '.soffice-profile');
mkdirSync(profileDir, { recursive: true });
// 1) pptx → pdf (LibreOffice)
await runSoffice(absPath, cacheDir, profileDir);
const pdfPath = join(cacheDir, `${parsePath(absPath).name}.pdf`);
if (!existsSync(pdfPath)) {
throw new Error('LibreOffice conversion did not produce a PDF');
}
// 2) pdf → png/枚 (PyMuPDF, PdfToImages と同方式)
await runPdfToPngs(pdfPath, cacheDir);
// 後始末: pdf とプロファイルは不要 (png だけ残す)
rmSync(pdfPath, { force: true });
rmSync(profileDir, { recursive: true, force: true });
const pngs = listSlidePngs(cacheDir);
if (pngs.length === 0) {
throw new Error('No slide images were generated');
}
writeFileSync(join(cacheDir, CACHE_MANIFEST), '');
return pngs;
}
function runSoffice(absPath: string, outDir: string, profileDir: string): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
err ? reject(err) : resolve();
};
const bin = sofficeBin();
const proc = spawn(
bin,
[
'--headless',
'--norestore',
'--nolockcheck',
`-env:UserInstallation=file://${profileDir}`,
'--convert-to',
'pdf',
'--outdir',
outDir,
absPath,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stderr = '';
proc.stdout.on('data', () => {});
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('error', (err) => {
clearTimeout(timer);
// soffice が PATH に無い → ENOENT。呼出側で 503 化する。
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
done(new SofficeUnavailableError(`'${bin}' not found on PATH`));
return;
}
done(new SofficeUnavailableError(err.message));
});
const timer = setTimeout(() => {
proc.kill('SIGKILL');
done(new Error(`LibreOffice conversion timed out after ${CONVERT_TIMEOUT_MS / 1000}s`));
}, CONVERT_TIMEOUT_MS);
proc.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
done(new Error(`LibreOffice exited ${code}: ${stderr.trim().slice(0, 500)}`));
return;
}
done();
});
});
}
function runPdfToPngs(pdfPath: string, outDir: string): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
err ? reject(err) : resolve();
};
const proc = spawn('python3', ['-'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
OFFICE_PDF_TO_PNG_ARGS: JSON.stringify({ pdf_path: pdfPath, output_dir: outDir, dpi: RENDER_DPI, max_pages: MAX_SLIDES }),
},
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('error', (err) => {
clearTimeout(timer);
done(new Error(`python3 process error: ${err.message}`));
});
const timer = setTimeout(() => {
proc.kill('SIGKILL');
done(new Error(`PDF render timed out after ${CONVERT_TIMEOUT_MS / 1000}s`));
}, CONVERT_TIMEOUT_MS);
proc.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
done(new Error(`PDF render failed (exit ${code}): ${(stderr || stdout).trim().slice(0, 500)}`));
return;
}
try {
const json = JSON.parse(stdout.trim()) as { error?: string };
if (json.error) { done(new Error(`PDF render error: ${json.error}`)); return; }
} catch {
done(new Error(`PDF render: failed to parse output: ${stdout.slice(0, 200)}`));
return;
}
done();
});
proc.stdin.write(PDF_TO_PNG_PYTHON);
proc.stdin.end();
});
}
// PdfToImages の Python と同じ PyMuPDF(fitz) で PDF を slide-N.png に変換する。
// 出力ファイル名は番号 1 始まり (slide-1.png ...)。
const PDF_TO_PNG_PYTHON = `
import sys, json, os
def main():
try:
import fitz
except ImportError:
print(json.dumps({'error': 'PyMuPDF (fitz) is not installed'}))
sys.exit(1)
raw = os.environ.get('OFFICE_PDF_TO_PNG_ARGS', '')
if not raw:
print(json.dumps({'error': 'OFFICE_PDF_TO_PNG_ARGS env var is not set'}))
sys.exit(1)
args = json.loads(raw)
pdf_path = args['pdf_path']
output_dir = args['output_dir']
dpi = int(args.get('dpi', 110))
max_pages = int(args.get('max_pages', 100))
os.makedirs(output_dir, exist_ok=True)
try:
doc = fitz.open(pdf_path)
except Exception as e:
print(json.dumps({'error': f'Failed to open PDF: {e}'}))
sys.exit(1)
scale = dpi / 72
mat = fitz.Matrix(scale, scale)
count = min(len(doc), max_pages)
for idx in range(count):
pix = doc[idx].get_pixmap(matrix=mat)
pix.save(os.path.join(output_dir, f'slide-{idx + 1}.png'))
print(json.dumps({'total_pages': len(doc), 'generated': count}))
main()
`;

View File

@ -0,0 +1,167 @@
/**
* reflection-api.space-authz.test.ts
*
* REFL-056 space-aware authorization NEGATIVE path.
*
* resolveStore() gates two ways depending on the operation:
* - read (GET /history, GET /history/:id) forEdit=false: only requires the
* space be VISIBLE to the viewer (membership OR ownership).
* - write (POST /history/:id/revert) forEdit=true: additionally requires
* canEditInSpace (admin / root owner / member role owner|editor). A read-only
* 'viewer' member must be REJECTED with 404 (no existence leak).
*
* The existing reflection-api.test.ts covers:
* - non-MEMBER 404 on history (space not visible at all).
* - editor/owner member happy path.
* It does NOT cover the distinct cross-principal gap: a member WITH view rights
* but WITHOUT edit rights ('viewer' role) hitting revert 404. That is the
* confused-deputy class a whole-branch review catches but per-unit tests miss.
*
* Fixture style copied from reflection-api.test.ts (real Repository + temp FS
* snapshots, supertest, x-test-user-id header synthetic req.user). authActive
* is TRUE here so membership/role gating actually runs (no-auth = admin-all).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { writeSnapshot, type WriteSnapshotMeta } from '../engine/reflection/snapshot.js';
import { createReflectionApi } from './reflection-api.js';
function makeMeta(spaceId: string, overrides: Partial<WriteSnapshotMeta> = {}): WriteSnapshotMeta {
return {
originalJobId: 'j-authz-001',
userId: spaceId, // snapshot leaf = space id (Space-as-Principal store)
pieceName: 'chat',
outcome: 'applied',
reasoning: 'Space-scoped learning.',
modelUsed: 'qwen2.5:3b',
tokensIn: 100,
tokensOut: 20,
ratingAtTime: null,
memoryChanges: 1,
pieceEdited: false,
...overrides,
};
}
// dataDir points at <tmp>/users so the spaces store is the sibling <tmp>/spaces,
// matching how resolveSpaceFolder derives the spaces tree from the users root.
function buildApp(usersDir: string, repo: Repository) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
const testUserId = req.headers['x-test-user-id'] as string | undefined;
if (testUserId) {
(req as any).user = {
id: testUserId,
role: testUserId === 'admin' ? 'admin' : 'user',
orgIds: [],
};
}
next();
});
// authActive:true → membership + canEditInSpace gating is exercised.
app.use('/api/local/reflection', createReflectionApi({ dataDir: usersDir, repo, authActive: true }));
return app;
}
describe('reflection-api space authz (REFL-056)', () => {
let tmpDir: string;
let usersDir: string;
let spacesDir: string;
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'reflect-authz-'));
usersDir = join(tmpDir, 'users');
spacesDir = join(tmpDir, 'spaces');
repo = new Repository(join(tmpDir, 'test.db'));
app = buildApp(usersDir, repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
/**
* Seed: a private case space owned by `owner`, with `member` added as a
* read-only 'viewer'. One snapshot written under the space store.
* Returns the principal/member ids + snapshotId.
*/
async function seedSpaceWithViewer() {
// addSpaceMember has an FK to users(id) → create real user rows.
const owner = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' });
const member = repo.createUser({ email: 'member@x.com', name: 'member', role: 'user', status: 'active' });
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' });
// Read-only role — can SEE the space, cannot edit/revert.
await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer', invitedBy: owner.id });
const { snapshotId } = await writeSnapshot(
{ dataDir: spacesDir }, {}, {},
makeMeta(space.id),
undefined, undefined, new Date('2026-05-12T10:00:00Z'),
);
return { owner, member, space, snapshotId };
}
it('a read-only viewer member CAN list space history (read is membership-gated only)', async () => {
const { member, space } = await seedSpaceWithViewer();
const res = await request(app)
.get(`/api/local/reflection/history?spaceId=${space.id}`)
.set('x-test-user-id', member.id);
// Read path: forEdit=false → only visibility required. Viewer is a member → 200.
expect(res.status).toBe(200);
expect(res.body.items.length).toBe(1);
});
it('a read-only viewer member CANNOT revert a space snapshot → 404 (no leak)', async () => {
const { member, space, snapshotId } = await seedSpaceWithViewer();
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert?spaceId=${space.id}`)
.set('x-test-user-id', member.id);
// Write path: forEdit=true → canEditInSpace('viewer') === false → resolveStore
// returns null → 404. Must NOT 403 (no existence leak) and must NOT 200.
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('the space owner (edit rights) CAN revert the same snapshot → 200', async () => {
const { owner, space, snapshotId } = await seedSpaceWithViewer();
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert?spaceId=${space.id}`)
.set('x-test-user-id', owner.id);
expect(res.status).toBe(200);
expect(res.body.reverted).toBe(true);
});
it('an editor member (edit rights) CAN revert → 200', async () => {
const { space, snapshotId } = await seedSpaceWithViewer();
const editor = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' });
await repo.addSpaceMember({ spaceId: space.id, userId: editor.id, role: 'editor', invitedBy: 'owner' });
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert?spaceId=${space.id}`)
.set('x-test-user-id', editor.id);
expect(res.status).toBe(200);
expect(res.body.reverted).toBe(true);
});
it('a complete non-member sees neither history nor revert → 404 both', async () => {
const { space, snapshotId } = await seedSpaceWithViewer();
const hist = await request(app)
.get(`/api/local/reflection/history?spaceId=${space.id}`)
.set('x-test-user-id', 'u-stranger');
expect(hist.status).toBe(404);
const revert = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert?spaceId=${space.id}`)
.set('x-test-user-id', 'u-stranger');
expect(revert.status).toBe(404);
});
});

View File

@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { createCoreServer } from './server.js';
/**
* Characterization test pinning the MCP + SSH subsystem wiring inside
* createCoreServer, so the extraction of those two blocks into dedicated
* setup helpers stays behavior-preserving. No test booted createCoreServer
* before this; the only runtime caller is worker-bootstrap (production).
*
* - MCP routes mount only when MCP_ENCRYPTION_KEY is configured (gate).
* - SSH is disabled by default config sshConsole is null (gate).
* - The whole server still boots and serves a basic route either way.
*/
describe('createCoreServer subsystem wiring', () => {
let tempDir: string;
let repo: Repository;
const KEY = 'a'.repeat(64); // 32-byte hex
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'core-subsys-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
});
afterEach(() => {
delete process.env.MCP_ENCRYPTION_KEY;
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('mounts MCP routers when MCP_ENCRYPTION_KEY is configured', async () => {
process.env.MCP_ENCRYPTION_KEY = KEY;
const { app, sshConsole, authActive } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
expect(typeof authActive).toBe('boolean');
// SSH is disabled by default config → the subsystem returns null.
expect(sshConsole).toBeNull();
// MCP admin router is mounted (not a 404 from the express fallthrough).
const res = await request(app).get('/api/mcp/servers');
expect(res.status).not.toBe(404);
});
it('does NOT mount MCP routers when the key is absent', async () => {
delete process.env.MCP_ENCRYPTION_KEY;
const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
const res = await request(app).get('/api/mcp/servers');
// No key → MCP block skipped → unmatched route → 404 fallthrough handler.
expect(res.status).toBe(404);
});
it('boots and serves /api/local/tasks regardless of MCP key', async () => {
const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
const res = await request(app).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tasks)).toBe(true);
});
});

View File

@ -24,6 +24,7 @@ import { mountBrandingApi, resolveBranding } from './branding-api.js';
import { createBrowserApi } from './browser-api.js';
import { createBrowserSessionApi } from './browser-session-api.js';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import { createDelegateRunsRouter } from './delegate-runs-api.js';
import { createUsageRouter } from './usage-api.js';
import { SessionManager } from '../engine/browser-session.js';
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
@ -31,12 +32,14 @@ import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
import { canUserSeeTask, canEditInSpace } from './visibility.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
import { canUserSeeTask } from './visibility.js';
import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
import { mountUsersApi, mountUsersPickableApi } from './users-api.js';
import { mountShareApi } from './share-api.js';
import { mountAppShareApi } from './app-share-api.js';
import { mountMetaApi } from './meta-api.js';
import { securityHeadersMiddleware } from './security-headers.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
import { findPieceFile } from './pieces-api.js';
@ -47,6 +50,9 @@ import { createMemoryApi } from './memory-api.js';
import { createReflectionApi } from './reflection-api.js';
import { createDashboardApi } from './dashboard-api.js';
import { createSpaceApi } from './space-api.js';
import { setupMcpSubsystem } from './mcp-subsystem.js';
import { setupSshSubsystem, type SshConsoleDeps } from './ssh-subsystem.js';
import { mountAppHarnessApi } from './app-harness-api.js';
import { createCrossCalendarApi } from './cross-calendar-api.js';
import { registerShutdownHook, installSignalHandlers } from './shutdown.js';
import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js';
@ -58,58 +64,7 @@ import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
import type { AuthConfig } from '../config.js';
import { isKeyConfigured } from '../mcp/crypto.js';
import { createRegistry } from '../mcp/registry.js';
import { createTokenManager } from '../mcp/token-manager.js';
import { createToolCache } from '../mcp/tool-cache.js';
import { createAggregator } from '../mcp/aggregator.js';
import { createMcpClient } from '../mcp/client-factory.js';
import { executeMcpCall } from '../mcp/tool-executor.js';
import { refreshAccessToken } from '../mcp/discovery.js';
import { setMcpAggregator } from '../engine/tools/index.js';
import { setMcpToolLookup } from '../engine/tools/docs.js';
import { setCalendarRepo } from '../engine/tools/calendar.js';
import { createMcpOauthRouter } from '../mcp/oauth-routes.js';
import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js';
import { mergeMcpConfig } from '../mcp/config.js';
import { mergeSshConfig } from '../ssh/config.js';
import {
bootstrapSystemDek,
verifySystemDek,
encryptPrivateKey as sshEncryptPrivateKey,
decryptPrivateKey as sshDecryptPrivateKey,
computeKeyFingerprint as sshComputeKeyFingerprint,
formatPublicKey as sshFormatPublicKey,
generateKeypair as sshGenerateKeypair,
type GeneratedKeyType as SshGeneratedKeyType,
} from '../ssh/crypto.js';
import { createConnectionRepo } from '../ssh/connection-repo.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import { createAuditRepo } from '../ssh/audit-repo.js';
import { createAbuseRepo } from '../ssh/abuse-repo.js';
import { createAccessResolver } from '../ssh/access.js';
import { maintenance as sshMaintenance } from '../ssh/maintenance.js';
import { createAdminRateLimiter, FORCE_UNLOCK_LIMIT } from '../ssh/admin-rate-limit.js';
import {
sshTest,
sshExec,
sshUpload,
sshDownload,
openShellChannel,
type ResolvedConnection as SshResolvedConnection,
} from '../ssh/session.js';
import { SessionRegistry } from '../ssh/console-registry.js';
import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js';
import { setSshSubsystem, preflight as sshPreflight, type SshSubsystem } from '../engine/tools/ssh.js';
import { __setActiveSessionLookup } from '../engine/agent-loop.js';
import {
attachConsoleWs,
createConsoleStatusRouter,
createConsoleSessionRouter,
type SimpleTask,
type SimpleUser,
} from './console-ws-api.js';
import { createConsoleAdminRouter } from './console-admin-api.js';
import { attachConsoleWs } from './console-ws-api.js';
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
import { readGatewayConfig } from '../gateway/config.js';
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
@ -161,17 +116,6 @@ export interface CoreServerOptions {
listenPort?: number;
}
export interface SshConsoleDeps {
registry: SessionRegistry;
resolveUserFromUpgrade: (req: import('http').IncomingMessage) => Promise<SimpleUser | null>;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
resolveSshAccess: (
user: SimpleUser,
session: import('../ssh/console-session.js').ConsoleSession,
task: SimpleTask,
) => Promise<boolean>;
denyPatterns: import('./console-ws-api.js').DenyPatternProvider;
}
export function createCoreServer(opts: CoreServerOptions): {
app: express.Application;
@ -468,6 +412,10 @@ export function createCoreServer(opts: CoreServerOptions): {
// --- Share API (public + authenticated routes) ---
mountShareApi(app, repo);
// --- 公開アプリ共有 APIread-only・認証なし---
// space-api と同じワークスペース解決loadConfig().worktreeDirを使う。
mountAppShareApi(app, repo, worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees');
// Redirect root to UI
app.get('/', (_req: Request, res: Response) => {
res.redirect('/ui');
@ -479,493 +427,33 @@ export function createCoreServer(opts: CoreServerOptions): {
app.get('/ui/*', (_req, res) => {
res.sendFile(join(uiDistPath, 'index.html'));
});
// Headless test-harness page for workspace-app E2E (Task 5/6).
// Must be registered BEFORE any SPA catch-all and BEFORE space-api routes
// so that the app-harness auth middleware (mounted below) sees the request.
// The harness JS/CSS chunks are served by the existing /ui static mount
// because Vite builds them with base: '/ui/' → /ui/assets/… paths.
app.get('/app-harness', (_req, res) => {
res.sendFile(join(uiDistPath, 'app-harness.html'));
});
}
// Version endpoint
app.get('/api/version', async (_req: Request, res: Response) => {
let version = 'dev';
try {
const mod = await import('../generated/version.js');
version = mod.APP_VERSION;
} catch {
try {
const { execSync } = await import('child_process');
version = execSync("TZ=UTC git log -1 --format=%cd --date=format:'%Y%m%d.%H%M%S'", { encoding: 'utf-8' }).trim();
} catch {
// keep 'dev'
}
}
res.json({ version });
});
app.get('/api/repos', (_req: Request, res: Response) => {
try {
const reposFromJobs = repo.getDistinctRepos();
const reposFromConfig = (opts.configuredRepos ?? []).filter(Boolean);
const repos = Array.from(new Set([...reposFromConfig, ...reposFromJobs])).sort();
res.json({ repos });
} catch {
res.status(500).json({ error: 'Failed to fetch repos' });
}
});
// Version + repos info endpoints (see meta-api.ts). Mounted here to preserve
// ordering relative to the `/api/repos` requireAuth guard registered above.
mountMetaApi(app, { repo, configuredRepos: opts.configuredRepos });
// Per-user browser session profile repo (envelope-encrypted storageState).
// Created up-front so APIs that bind a profile to a task (local + scheduled)
// can run an owner-scoped check before persisting.
const sessRepo = new BrowserSessionRepo(repo.getDb());
// Surfaces per-user MCP servers into /api/tools so the Piece allowed_tools
// editor can include them. Populated by the MCP block below when the
// subsystem initialises; remains null otherwise.
let mcpCatalogDeps: import('./tools-api.js').McpCatalogDeps | null = null;
const mcpCatalogDeps = setupMcpSubsystem(app, {
repo,
authActive,
workerManager: opts.workerManager,
callbackBaseUrl: deriveCallbackBaseUrl(opts.authConfig),
});
// MCP subsystem (gated on MCP_ENCRYPTION_KEY)
{
if (isKeyConfigured()) {
const mcpConfig = mergeMcpConfig(loadConfig().mcp);
const mcpRegistry = createRegistry(repo.getDb());
const mcpTokenManager = createTokenManager(repo.getDb(), {
doRefresh: async (serverId: string, refreshToken: string) => {
const server = mcpRegistry.getDecrypted(serverId);
if (!server || !server.tokenEndpoint) {
throw new Error(`server or token endpoint missing for ${serverId}`);
}
return refreshAccessToken({
tokenEndpoint: server.tokenEndpoint,
clientId: server.oauthClientId,
clientSecret: server.oauthClientSecret,
refreshToken,
});
},
});
const mcpToolCache = createToolCache(repo.getDb(), mcpConfig.toolCacheTtlSeconds);
const mcpAggregator = createAggregator({
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
executeCall: async (args) => {
const server = mcpRegistry.getDecrypted(args.serverId);
if (!server) return { output: `未登録の MCP サーバー: ${args.serverId}`, isError: true };
const { client, close } = await createMcpClient(server, args.accessToken, {
callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
});
try {
return await executeMcpCall({
client,
serverId: args.serverId,
toolName: args.toolName,
input: args.input,
ctx: args.ctx,
});
} finally {
await close();
}
},
});
setMcpAggregator(mcpAggregator);
setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName));
setCalendarRepo(repo);
opts.workerManager?.setMcpDeps({ tokenManager: mcpTokenManager });
// Expose MCP enumeration to /api/tools so the Piece allowed_tools editor
// can list per-user MCP tools alongside builtin ones. Methods on the
// registry/tokenManager/toolCache surfaces are already DB-backed and
// safe to call per-request.
mcpCatalogDeps = {
registry: { listEnabledForUser: (uid) => mcpRegistry.listEnabledForUser(uid) },
tokenManager: { hasToken: (uid, sid) => mcpTokenManager.hasToken(uid, sid) },
toolCache: { getAllForServers: (ids) => mcpToolCache.getAllForServers(ids) },
};
const callbackBaseUrl = deriveCallbackBaseUrl(opts.authConfig);
app.use(
'/auth/mcp',
createMcpOauthRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
pendingTtlMinutes: mcpConfig.oauthPendingTtlMinutes,
getCallbackBaseUrl: () => callbackBaseUrl,
getAuthenticatedUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
resumeWaitingJobs: (uid, sid) => {
repo.resumeMcpWaitingJobs(uid, sid);
},
listToolsAfterAuth: async (serverId: string, accessToken: string) => {
const server = mcpRegistry.getDecrypted(serverId);
if (!server) return;
const { client, close } = await createMcpClient(server, accessToken, {
callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
});
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
mcpToolCache.replaceForServer(serverId, list.tools);
logger.info(
`[mcp] auto list_tools after OAuth server=${serverId} count=${list.tools.length}`,
);
} finally {
await close();
}
},
}),
);
app.use('/api/mcp/servers', express.json(), createMcpAdminRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
}));
app.use('/api/mcp/connections', express.json(), createMcpUserRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
}));
app.use('/api/mcp/user-servers', express.json(), createMcpUserServersRouter({
db: repo.getDb(),
registry: mcpRegistry,
tokenManager: mcpTokenManager,
toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
// No-auth single-user mode: passport is not mounted, so req.user is
// never populated. Fall back to the synthetic `local` admin (the same
// owner the SSH user router and space API use) so per-user / per-space
// MCP server management stays usable; otherwise getUserId is null and
// every register/list 401s, making the per-space MCP panel dead in
// no-auth deployments. No-op when auth is active (req.user wins).
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? (authActive ? null : 'local'),
// Mirror the no-auth fallback: when auth is off and passport hasn't
// populated a viewer, authorize the space against the synthetic `local`
// admin so getSpace doesn't reject the (null-owner) local space.
getSpace: (spaceId, viewer) =>
repo.getSpace(spaceId, {
viewer: viewer ?? (authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User)),
}),
// Space-as-Principal P2: edit-rights check so space members can share the
// management of space-owned api_key MCP servers. Mirrors the no-auth
// synthetic local admin used by getUserId/getSpace above.
canEditSpace: async (spaceId, req) => {
const viewer =
(req.user as Express.User | undefined) ??
(authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User));
if (!viewer) return false;
const space = await repo.getSpace(spaceId, { viewer });
if (!space) return false;
const role = repo.getSpaceMemberRole(spaceId, viewer.id);
return canEditInSpace(viewer, { ownerId: space.ownerId }, role);
},
insecureLocalTestMode: false,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
}));
logger.info('[mcp] subsystem initialised');
} else {
logger.warn('[mcp] MCP_ENCRYPTION_KEY not configured — MCP features disabled');
}
}
// SSH subsystem (gated on ssh.enabled AND MCP_ENCRYPTION_KEY)
// Phase 5 (SSH Console): sshConsole is captured here so that
// startCoreServer() can wire the WS upgrade hook to the http.Server
// it eventually creates. null when SSH is disabled / failed init.
let sshConsole: SshConsoleDeps | null = null;
{
const sshConfig = mergeSshConfig(loadConfig().ssh);
if (!sshConfig.enabled) {
setSshSubsystem(null);
__setActiveSessionLookup(null);
} else if (!isKeyConfigured()) {
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
setSshSubsystem(null);
__setActiveSessionLookup(null);
} else {
try {
bootstrapSystemDek(repo.getDb());
verifySystemDek(repo.getDb());
const connectionRepo = createConnectionRepo(repo.getDb());
const grantsRepo = createGrantsRepo(repo.getDb());
const auditRepo = createAuditRepo(repo.getDb());
const abuseRepo = createAbuseRepo(repo.getDb(), {
windowMinutes: sshConfig.abuseWindowMinutes,
failureThreshold: sshConfig.abuseFailureThreshold,
lockMinutes: sshConfig.abuseLockMinutes,
});
const accessResolver = createAccessResolver(grantsRepo, {
adminBypassesGrants: sshConfig.adminBypassesGrants,
});
const forceUnlockLimiter = createAdminRateLimiter(FORCE_UNLOCK_LIMIT);
// Hoisted above sshDeps so the grant-revocation hook can call
// sessionRegistry.revokeAccessFor (introduced for Phase 5 hardening:
// kick active WS viewers when their grant is deleted).
const sessionRegistry = new SessionRegistry({
idleTimeoutMs: sshConfig.console.idleTimeoutSeconds * 1000,
maxSessionDurationMs: sshConfig.console.maxSessionDurationSeconds * 1000,
maxSessionsPerConnection: sshConfig.console.maxSessionsPerConnection,
});
const sshDeps: SshApiDeps = {
db: repo.getDb(),
authActive,
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin',
getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
connectionRepo,
grantsRepo,
auditRepo,
abuseRepo,
accessResolver,
maintenance: sshMaintenance,
forceUnlockLimiter,
encryptKeyMaterial: (ownerId, pem, passphrase, spaceId) => {
const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem, spaceId);
const passphraseBlob = passphrase
? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase, spaceId).blob
: null;
const fingerprint = sshComputeKeyFingerprint(pem, passphrase);
const publicKey = sshFormatPublicKey(pem, passphrase);
return { blob, passphraseBlob, keyVersion, fingerprint, publicKey };
},
decryptKeyMaterial: (ownerId, blob, spaceId) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
decryptPassphrase: (ownerId, blob, spaceId) =>
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType),
derivePublicKey: (ownerId, blob, passphraseBlob, spaceId) => {
const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId);
const pass = passphraseBlob
? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob, spaceId)
: null;
try {
return sshFormatPublicKey(pem, pass);
} finally {
pem.fill(0);
if (pass) pass.fill(0);
}
},
sshTester: {
async test({ connection, decryptedKey, passphrase, timeoutMs }) {
const conn: SshResolvedConnection = {
id: connection.id,
ownerId: connection.ownerId,
host: connection.host,
port: connection.port,
username: connection.username,
privateKeyPem: decryptedKey,
passphrase: passphrase ?? undefined,
hostKeyB64: connection.hostKeyB64,
hostKeyVerified: connection.hostKeyVerifiedAt !== null,
allowPrivate:
sshConfig.allowPrivateAddresses || connection.allowPrivateAddresses,
};
return sshTest({ connection: conn, timeoutMs });
},
},
connectionTestTimeoutMs: sshConfig.callTimeoutSeconds * 1000,
onAccessRevoked: ({ connectionId, userId }) =>
sessionRegistry.revokeAccessFor({
connectionId,
userId,
reason: 'access_revoked',
}),
};
app.use('/api/ssh/admin', express.json(), createSshAdminRouter(sshDeps));
app.use('/api/ssh', express.json(), createSshUserRouter(sshDeps));
// Phase 7: register the SSH tool subsystem so SshExec / SshUpload /
// SshDownload tools can access the same repos / session primitives /
// crypto wrappers that the HTTP layer uses. sessionRegistry is
// constructed above (hoisted so sshDeps.onAccessRevoked can use it).
// Captured in a local const (not just passed to setSshSubsystem)
// so the user-initiated console-session REST endpoint can call the
// shared openConsoleSession core with the EXACT same `sub` the
// agent-facing console tools use — no second SshSubsystem.
const sshSubsystem: SshSubsystem = {
connectionRepo,
auditRepo,
abuseRepo,
accessResolver,
decryptKeyMaterial: (ownerId, blob, spaceId) =>
sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
decryptPassphrase: (ownerId, blob, spaceId) =>
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
getUserAccess: (userId) => {
const user = repo.getUserById(userId);
const isAdmin = user?.role === 'admin';
const orgIds = resolveOrgIds(repo, userId);
return { isAdmin, orgIds };
},
sshExec,
sshUpload,
sshDownload,
maintenance: sshMaintenance,
config: sshConfig,
sessionRegistry,
openShellChannel,
};
setSshSubsystem(sshSubsystem);
// Phase 4 (SSH Console): wire the registry into agent-loop so
// buildSystemPrompt can auto-inject the live screen tail into
// the LLM system prompt for movements that allow SshConsole*.
__setActiveSessionLookup((taskId) => sessionRegistry.get(taskId));
// Phase 5 (SSH Console): start the periodic sweep so idle /
// duration-cap sessions actually get closed. Without this, the
// registry just holds sessions until shutdown.
sessionRegistry.startSweepTimer(60_000);
// Phase 5 (SSH Console): when SSH maintenance mode activates
// (master-key rotation), close all live console sessions. They
// would otherwise hold a decrypted DEK reference past the
// rewrap window. The reason 'maintenance' is surfaced to the
// WS client as the close cause.
sshMaintenance.onEnter(async () => {
const all = sessionRegistry.listAll();
for (const s of all) {
await sessionRegistry.closeForTask(s.localTaskId, 'maintenance');
}
});
// Capture WS / status deps for startCoreServer to wire up.
const consoleDeps: SshConsoleDeps = {
registry: sessionRegistry,
resolveUserFromUpgrade: async (req) => {
if (!authenticateUpgrade) {
// No-auth single-user mode: synthesize a stable `local` admin
// user so the Console terminal WS attaches (admin role makes
// the null-owner no-auth task visible in resolveTask). Mirrors
// the Console REST routers and notifications-api.
return { id: 'local', role: 'admin' };
}
const u = await authenticateUpgrade(req);
return u ? { id: u.id, role: u.role } : null;
},
resolveTask: async (taskId, user) => {
const idNum = Number(taskId);
if (!Number.isFinite(idNum)) return null;
const viewer: Express.User = {
id: user.id,
email: '',
name: null,
avatarUrl: null,
role: (user.role === 'admin' ? 'admin' : 'user'),
status: 'active',
orgIds: resolveOrgIds(repo, user.id),
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.getLocalTask(idNum, { viewer });
if (!task) return null;
return {
id: String(task.id),
ownerId: task.ownerId ?? '',
visibility: task.visibility,
pieceName: task.pieceName,
};
},
resolveSshAccess: async (user, session, task) => {
const connection = connectionRepo.resolveConnection(session.connectionId);
if (!connection) return false;
const orgIds = resolveOrgIds(repo, user.id);
const decision = accessResolver.resolveAccess({
connection,
userId: user.id,
isAdmin: user.role === 'admin',
// Use the task's actual piece name so piece-specific grants in
// ssh_connection_grants match (applies_to_all_pieces=0 case).
// Bug pre-fix: hardcoded '' silently failed every piece-scoped grant.
pieceName: task.pieceName,
orgIds,
});
return decision.allowed;
},
denyPatterns: {
async getPatterns(connectionId: string) {
const c = connectionRepo.resolveConnection(connectionId);
if (!c) return { deny: [], allow: [] };
const split = (s: string | null): string[] =>
s ? s.split('\n').map((x) => x.trim()).filter((x) => x.length > 0) : [];
return {
deny: split(c.commandDenyPatterns),
allow: split(c.commandAllowPatterns),
};
},
},
};
sshConsole = consoleDeps;
// REST status endpoint: /api/local/tasks/:taskId/console/status
app.use(
'/api',
createConsoleStatusRouter({
registry: sessionRegistry,
authActive,
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
resolveTask: consoleDeps.resolveTask,
}),
);
// REST user-initiated session-open endpoint:
// POST /api/local/tasks/:taskId/console/session. Reuses the same
// SshSubsystem + preflight the console tools use; the access gate
// runs inside openConsoleSession against task.pieceName.
// NOTE: no express.json() here — the router is mounted on the whole
// /api prefix, so a mount-level parser (default limit 100kb) would
// run for EVERY /api request and 413 large bodies before the
// route-specific parsers (e.g. the task-attachment limit) ever ran.
// The session route carries its own scoped json() parser.
app.use(
'/api',
createConsoleSessionRouter({
sub: sshSubsystem,
preflight: sshPreflight,
authActive,
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
resolveTask: consoleDeps.resolveTask,
}),
);
// Phase 6 (SSH Console): admin list + kill endpoints. The
// `/api/admin` prefix already has `express.json()` mounted above
// (see Admin user management API), so POST bodies parse correctly.
app.use(
'/api/admin',
createConsoleAdminRouter({
registry: sessionRegistry,
requireAdmin: authActive ? requireAdmin : (_req: Request, _res: Response, next: NextFunction) => next(),
}),
);
logger.info('[ssh] subsystem initialised');
} catch (e) {
logger.error(`[ssh] init failed err=${String(e)}`);
setSshSubsystem(null);
__setActiveSessionLookup(null);
}
}
}
const sshConsole = setupSshSubsystem(app, { repo, authActive, authenticateUpgrade });
// --- Local tasks API ---
mountLocalTasksApi(app, {
@ -999,6 +487,7 @@ export function createCoreServer(opts: CoreServerOptions): {
// --- Subtask activity API ---
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
app.use('/api/usage', createUsageRouter(repo, { authActive }));
// 横断(全スペース)カレンダー: GET /api/calendar/cross
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
@ -1221,6 +710,10 @@ export function createCoreServer(opts: CoreServerOptions): {
// Per-user reflection history REST API (GET/POST).
app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive }));
// App-harness token-auth middleware (Task 5).
// Must run BEFORE space-api so that req.user is populated before viewerOf() reads it.
mountAppHarnessApi(app);
// スペース(個人 / 案件CRUD REST API。
// NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
// upload ルートの大きい json parser より前に body を消費し、>100kb のアップロードが

View File

@ -0,0 +1,135 @@
/**
* APIS-019 Shared-subtask wildcard traversal hardening.
*
* Public token route: GET /api/shared/:token/subtasks/:jobId/files/*
* Containment relies on a separator-bounded prefix check:
*
* const base = resolve(job.worktreePath);
* const resolved = resolve(base, req.params[0]);
* if (resolved !== base && !resolved.startsWith(base + sep)) -> 403
*
* share-api.test.ts already asserts one `..` escape, cross-task containment,
* and a sibling prefix-collision at the WORKTREE level. This file targets the
* WILDCARD path segment (`req.params[0]`) directly with a battery of escape
* payloads encoded `..`, deep `..`, absolute-path injection, and a sibling
* prefix-collision reached THROUGH the wildcard asserting each is rejected
* (403/404) and the out-of-bounds secret is never served.
*
* Public route: no auth middleware mounted (token is the only credential).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import express from 'express';
import request from 'supertest';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountShareApi } from './share-api.js';
const SECRET = 'TOP-SECRET-DO-NOT-LEAK';
/**
* Seed a shared task whose subtask worktree sits under the task workspace,
* plus a sibling directory `<taskWs>/subtasks/1-evil` that shares a string
* prefix with the legit subtask worktree `<taskWs>/subtasks/1`.
*/
async function seed(repo: Repository, tempDir: string) {
const task = await repo.createLocalTask({ title: 'parent', body: 'b' });
const taskWs = join(tempDir, 'ws');
mkdirSync(taskWs, { recursive: true });
await repo.updateLocalTask(task.id, { workspacePath: taskWs });
const repoName = localTaskRepoName(task.id);
const parent = await repo.createJob({ repo: repoName, issueNumber: task.id, instruction: 'parent' });
const sub = await repo.createJob({ repo: repoName, issueNumber: 1, instruction: 'sub', parentJobId: parent.id });
const subWs = join(taskWs, 'subtasks', '1');
mkdirSync(join(subWs, 'output'), { recursive: true });
writeFileSync(join(subWs, 'output', 'sub-result.md'), '# legit sub');
await repo.updateJob(sub.id, { worktreePath: subWs });
// Secret OUTSIDE the subtask worktree but inside the task workspace.
writeFileSync(join(taskWs, 'secret.txt'), SECRET);
// Sibling whose path is a string-prefix of the subtask worktree (1 vs 1-evil).
const siblingDir = join(taskWs, 'subtasks', '1-evil');
mkdirSync(siblingDir, { recursive: true });
writeFileSync(join(siblingDir, 'leak.txt'), SECRET);
// Secret well outside the whole task tree.
writeFileSync(join(tempDir, 'host-secret.txt'), SECRET);
const token = (await repo.shareLocalTask(task.id)) as string;
return { token, subJobId: sub.id, taskWs };
}
function setup() {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-share-wildcard-'));
const repo = new Repository(join(tempDir, 'test.db'));
const app = express();
app.use(express.json());
// Public share route — intentionally NO auth middleware.
mountShareApi(app, repo);
return { app, repo, tempDir };
}
describe('APIS-019 shared-subtask wildcard traversal', () => {
let tempDir = '';
let repo: Repository | null = null;
afterEach(() => {
if (repo) { repo.close(); repo = null; }
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
it('serves a legitimate in-bounds file (control)', async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
const { token, subJobId } = await seed(ctx.repo, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/output/sub-result.md`);
expect(res.status).toBe(200);
expect(res.text).toContain('# legit sub');
});
// Each payload must be rejected (403 containment, or 404 not-found) and must
// NOT return the secret in the body.
const payloads: Array<{ label: string; seg: string }> = [
{ label: 'encoded single-level ..', seg: '..%2Fsecret.txt' },
{ label: 'encoded deep ..', seg: '..%2F..%2Fhost-secret.txt' },
{ label: 'mixed encoded + literal ..', seg: 'output%2F..%2F..%2Fsecret.txt' },
{ label: 'sibling prefix-collision via wildcard', seg: '..%2F1-evil%2Fleak.txt' },
{ label: 'double-encoded ..', seg: '..%252Fsecret.txt' },
{ label: 'trailing traversal back into parent', seg: 'output%2F..%2F..%2F..%2Fhost-secret.txt' },
];
for (const { label, seg } of payloads) {
it(`rejects traversal payload: ${label}`, async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
const { token, subJobId } = await seed(ctx.repo, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/${seg}`);
// Acceptable rejections: 403 (containment) or 404 (resolved-but-not-found).
expect([403, 404], `status for ${label}`).toContain(res.status);
// The secret must never be served.
expect(res.text ?? '', `body for ${label}`).not.toContain(SECRET);
});
}
it('an absolute-path wildcard segment cannot escape the worktree', async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
const { token, subJobId } = await seed(ctx.repo, tempDir);
// resolve(base, '/etc/passwd') => '/etc/passwd' (absolute wins), which is
// outside base → must be rejected, not served.
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/%2Fetc%2Fpasswd`);
expect([403, 404]).toContain(res.status);
expect(res.text ?? '').not.toContain('root:');
});
it('unknown token on the wildcard route → 404 (not a 500/leak)', async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
await seed(ctx.repo, tempDir);
const res = await request(ctx.app).get('/api/shared/bogus-token/subtasks/999/files/output/sub-result.md');
expect(res.status).toBe(404);
});
});

View File

@ -4,9 +4,37 @@ import { join } from 'path';
import { tmpdir } from 'os';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountShareApi } from './share-api.js';
// 共有された 1 タスクに、output あり worktree を持つサブジョブ 1 件を仕込む。
// 親ジョブの repo/issue は getLatestJobForIssue が引けるよう localTaskRepoName に揃える。
async function seedSharedTaskWithSubtask(
ctx: { repo: Repository },
tempDir: string,
): Promise<{ token: string; subJobId: string; taskWs: string }> {
const task = await ctx.repo.createLocalTask({ title: 'parent', body: 'b' });
const taskWs = join(tempDir, 'ws');
mkdirSync(taskWs, { recursive: true });
await ctx.repo.updateLocalTask(task.id, { workspacePath: taskWs });
const repoName = localTaskRepoName(task.id);
const parent = await ctx.repo.createJob({ repo: repoName, issueNumber: task.id, instruction: 'parent' });
const sub = await ctx.repo.createJob({ repo: repoName, issueNumber: 1, instruction: 'sub', parentJobId: parent.id });
// サブジョブの worktree は親タスクの workspacePath 配下に置く封じ込めOK
const subWs = join(taskWs, 'subtasks', '1');
mkdirSync(join(subWs, 'output'), { recursive: true });
mkdirSync(join(subWs, 'logs'), { recursive: true });
writeFileSync(join(subWs, 'output', 'sub-result.md'), '# sub');
writeFileSync(join(subWs, 'logs', 'activity.log'), 'sub activity line');
writeFileSync(join(subWs, 'secret-sibling.txt'), 'do-not-leak');
await ctx.repo.updateJob(sub.id, { worktreePath: subWs });
const token = (await ctx.repo.shareLocalTask(task.id)) as string;
return { token, subJobId: sub.id, taskWs };
}
function setup(user?: { id: string; role: 'admin' | 'user' }) {
const tempDir = mkdtempSync(join(tmpdir(), 'share-api-'));
const repo = new Repository(join(tempDir, 'test.db'));
@ -204,4 +232,105 @@ describe('Share API', () => {
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
});
// --- Shared individual subtask endpoints (read-only, containment) ---
it('GET /api/shared/:token/subtasks/:jobId/activity returns the activity log', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/activity`);
expect(res.status).toBe(200);
expect(res.body.activityLog).toContain('sub activity line');
});
it('GET /api/shared/:token/subtasks/:jobId/files lists subtask files by category', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files`);
expect(res.status).toBe(200);
expect(res.body.files).toEqual(['sub-result.md']);
expect(res.body.categories.output).toEqual(['sub-result.md']);
expect(res.body.categories.logs).toEqual(['activity.log']);
});
it('GET /api/shared/:token/subtasks/:jobId/files/* serves an individual subtask file', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/output/sub-result.md`);
expect(res.status).toBe(200);
expect(res.text).toContain('# sub');
});
it('rejects traversal out of the subtask worktree with 403 and no leak', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const { token, subJobId } = await seedSharedTaskWithSubtask(ctx, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/..%2Fsecret-sibling.txt`);
expect(res.status).toBe(403);
expect(res.text).not.toContain('do-not-leak');
});
it('returns 404 for a subtask outside the shared task workspace (containment)', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const { token } = await seedSharedTaskWithSubtask(ctx, tempDir);
// A second, unshared task with its own subtask. Reaching its subtask via the
// first task's share token must fail (the jobId is not under task.workspacePath).
const other = await ctx.repo.createLocalTask({ title: 'other', body: 'b' });
const otherWs = join(tempDir, 'other-ws');
mkdirSync(join(otherWs, 'output'), { recursive: true });
await ctx.repo.updateLocalTask(other.id, { workspacePath: otherWs });
const oRepo = localTaskRepoName(other.id);
const oParent = await ctx.repo.createJob({ repo: oRepo, issueNumber: other.id, instruction: 'p' });
const oSub = await ctx.repo.createJob({ repo: oRepo, issueNumber: 1, instruction: 's', parentJobId: oParent.id });
await ctx.repo.updateJob(oSub.id, { worktreePath: otherWs });
for (const ep of ['activity', 'files']) {
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${oSub.id}/${ep}`);
expect(res.status, ep).toBe(404);
}
});
it('returns 404 for a sibling task whose workspace is a string prefix of the shared one (prefix-collision containment)', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
// Shared task workspace is `<tempDir>/ws`. A sibling at `<tempDir>/ws-evil`
// shares that string prefix but is a different directory — a bare
// `startsWith` containment check would wrongly admit it (the /wt/local/12 vs
// /wt/local/123 越境). The separator-bounded check must reject it.
const { token } = await seedSharedTaskWithSubtask(ctx, tempDir);
const evil = await ctx.repo.createLocalTask({ title: 'evil', body: 'b' });
const evilWs = join(tempDir, 'ws-evil');
mkdirSync(join(evilWs, 'output'), { recursive: true });
mkdirSync(join(evilWs, 'logs'), { recursive: true });
writeFileSync(join(evilWs, 'output', 'leak.md'), 'secret-from-other-task');
writeFileSync(join(evilWs, 'logs', 'activity.log'), 'victim activity');
const eRepo = localTaskRepoName(evil.id);
const eParent = await ctx.repo.createJob({ repo: eRepo, issueNumber: evil.id, instruction: 'p' });
const eSub = await ctx.repo.createJob({ repo: eRepo, issueNumber: 1, instruction: 's', parentJobId: eParent.id });
await ctx.repo.updateJob(eSub.id, { worktreePath: evilWs });
for (const ep of ['activity', 'files', 'files/output/leak.md']) {
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${eSub.id}/${ep}`);
expect(res.status, ep).toBe(404);
}
});
it('returns 404 for shared subtask endpoints with an unknown token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const a = await request(ctx.app).get('/api/shared/nope/subtasks/job-x/activity');
expect(a.status).toBe(404);
const f = await request(ctx.app).get('/api/shared/nope/subtasks/job-x/files');
expect(f.status).toBe(404);
});
});

View File

@ -1,10 +1,10 @@
import express, { Request, Response } from 'express';
import { readdirSync, statSync, readFileSync, mkdirSync } from 'fs';
import { join, extname } from 'path';
import { readdirSync, statSync, readFileSync, mkdirSync, existsSync } from 'fs';
import { join, extname, resolve, sep } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership, ensurePathWithin, isPathEscapeError, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
import { checkTaskOwnership, ensurePathWithin, isPathEscapeError, isJobWithinWorkspace, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
function sanitizeTaskForPublic(task: Record<string, unknown>): Record<string, unknown> {
const { ownerId, workspacePath, body, ...safe } = task;
@ -143,6 +143,102 @@ export function mountShareApi(app: express.Application, repo: Repository): void
}
});
// 共有トークン → サブジョブの解決(封じ込め付き)。
// 1. token が有効なタスクを引く(失効/不正は null
// 2. jobId のサブジョブを引くworktreePath 必須)
// 3. サブジョブの worktree が共有タスクの workspacePath 配下であることを要求
// = 他タスクのサブタスクへ token を流用しても 404
// 認証付き版subtask-files-api / subtask-activity-apiと同じ封じ込め流儀。
async function resolveSharedSubJob(
token: string,
): Promise<{ workspacePath: string } | null> {
const task = await repo.getLocalTaskByShareToken(token);
if (!task || !task.workspacePath) return null;
return { workspacePath: task.workspacePath };
}
// 個別サブタスクの活動ログ共有・read-only
app.get('/api/shared/:token/subtasks/:jobId/activity', async (req: Request, res: Response) => {
try {
const shared = await resolveSharedSubJob(req.params.token);
if (!shared) { res.status(404).json({ error: 'Not found' }); return; }
const job = await repo.getJob(req.params.jobId);
if (!job || !job.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; }
// 封じ込め: 共有タスクの workspace 配下のサブジョブのみ。
if (!isJobWithinWorkspace(shared.workspacePath, job.worktreePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const logPath = join(job.worktreePath, 'logs', 'activity.log');
const activityLog = existsSync(logPath) ? readFileSync(logPath, 'utf-8') : '';
res.json({ activityLog });
} catch (err) {
logger.error(`Shared subtask activity API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activity' });
}
});
// 個別サブタスクのファイル一覧共有・read-only。listing は wildcard より先に登録。
app.get('/api/shared/:token/subtasks/:jobId/files', async (req: Request, res: Response) => {
try {
const shared = await resolveSharedSubJob(req.params.token);
if (!shared) { res.status(404).json({ error: 'Not found' }); return; }
const job = await repo.getJob(req.params.jobId);
if (!job || !job.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; }
if (!isJobWithinWorkspace(shared.workspacePath, job.worktreePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const base = resolve(job.worktreePath);
const categories: Record<string, string[]> = {};
for (const dir of ['output', 'logs', 'input']) {
const dirPath = resolve(base, dir);
if (!existsSync(dirPath)) continue;
const dirFiles = readdirSync(dirPath, { recursive: true })
.map((f) => String(f))
.filter((f) => !statSync(resolve(dirPath, f)).isDirectory());
if (dirFiles.length > 0) categories[dir] = dirFiles;
}
res.json({ files: categories['output'] ?? [], categories });
} catch (err) {
logger.error(`Shared subtask files API error: ${err}`);
res.status(500).json({ error: 'Failed to list subtask files' });
}
});
// 個別サブタスクのファイル本体共有・read-only
app.get('/api/shared/:token/subtasks/:jobId/files/*', async (req: Request, res: Response) => {
try {
const shared = await resolveSharedSubJob(req.params.token);
if (!shared) { res.status(404).json({ error: 'Not found' }); return; }
const job = await repo.getJob(req.params.jobId);
if (!job || !job.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; }
if (!isJobWithinWorkspace(shared.workspacePath, job.worktreePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const base = resolve(job.worktreePath);
const resolved = resolve(base, req.params[0]);
// 末尾セパレータ必須で sibling<base>-x 等)の prefix 一致を弾く。base 自体は許可。
if (resolved !== base && !resolved.startsWith(base + sep)) {
res.status(403).json({ error: 'Access denied' }); return;
}
if (!existsSync(resolved)) { res.status(404).json({ error: 'File not found' }); return; }
const stat = statSync(resolved);
if (stat.isDirectory()) { res.json({ files: readdirSync(resolved) }); return; }
setUntrustedFileResponseHeaders(res);
res.sendFile(resolved);
} catch (err) {
logger.error(`Shared subtask file API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask file' });
}
});
// ── 認証付きエンドポイント ──
app.post('/api/local/tasks/:taskId/share', express.json(), async (req: Request, res: Response) => {

View File

@ -0,0 +1,131 @@
// 公開アプリ共有リンクの発行/失効/取得エンドポイント(認証付き)のテスト。
//
// 発行/失効/取得は canManageSpaceowner / admin / member.role==='owner')。
// editor/viewer メンバーや非メンバーは 403。発行は未失効リンクの再利用。
// spec: docs/superpowers/specs/2026-06-23-public-app-share-link-design.md
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id,
email: `${id}@x.com`,
name: id,
avatarUrl: null,
role,
status: 'active',
orgIds,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
} as unknown as Express.User;
}
describe('space app-share link API', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
let editorId = '';
let strangerId = '';
let spaceId = '';
let worktreeDir = '';
const appName = 'dashboard';
function appAs(u: Express.User | null, authActive = true): express.Application {
const app = express();
if (u) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
}
const deps: SpaceApiDeps = {
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir,
authActive,
};
app.use('/api/local/spaces', createSpaceApi(deps));
return app;
}
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'space-app-share-'));
worktreeDir = join(dir, 'workspaces');
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId, visibility: 'private' });
spaceId = space.id;
await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
it('owner がリンクを発行できるtoken + shareUrl', async () => {
const res = await request(appAs(user(ownerId)))
.post(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(res.status).toBe(201);
expect(res.body.token).toBeTruthy();
expect(res.body.shareUrl).toBe(`/ui/app/${res.body.token}`);
});
it('GET は発行前は { token: null }、発行後は現行リンクを返す', async () => {
const app = appAs(user(ownerId));
const before = await request(app).get(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(before.status).toBe(200);
expect(before.body.token).toBeNull();
const created = (await request(app).post(`/api/local/spaces/${spaceId}/apps/${appName}/share`)).body;
const after = await request(app).get(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(after.status).toBe(200);
expect(after.body.token).toBe(created.token);
expect(after.body.shareUrl).toBe(`/ui/app/${created.token}`);
expect(after.body.revokedAt).toBeNull();
});
it('未失効の再発行は同一トークンを返す', async () => {
const app = appAs(user(ownerId));
const first = (await request(app).post(`/api/local/spaces/${spaceId}/apps/${appName}/share`)).body.token;
const second = (await request(app).post(`/api/local/spaces/${spaceId}/apps/${appName}/share`)).body.token;
expect(second).toBe(first);
});
it('DELETE で失効でき、その後 GET は revokedAt を持つ', async () => {
const app = appAs(user(ownerId));
await request(app).post(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
const del = await request(app).delete(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(del.status === 200 || del.status === 204).toBe(true);
const after = await request(app).get(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(after.body.revokedAt).not.toBeNull();
});
it('editor メンバーは発行できない403', async () => {
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(res.status).toBe(403);
});
it('非メンバーはスペースが見えず 404', async () => {
const res = await request(appAs(user(strangerId)))
.post(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
// private スペースは非メンバーに getSpace で null → 404
expect(res.status).toBe(404);
});
it('admin は発行できる', async () => {
const res = await request(appAs(user('admin-1', 'admin')))
.post(`/api/local/spaces/${spaceId}/apps/${appName}/share`);
expect(res.status).toBe(201);
expect(res.body.token).toBeTruthy();
});
});

View File

@ -113,6 +113,84 @@ describe('space-api calendar', () => {
});
});
describe('end_time (startend time range) via HTTP', () => {
it('creates an event with start + end time (201)', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 'mtg', time: '09:00', end_time: '10:30' });
expect(res.status).toBe(201);
expect(res.body.time).toBe('09:00');
expect(res.body.endTime).toBe('10:30');
});
it('end_time without a start time → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 't', end_time: '10:30' });
expect(res.status).toBe(400);
});
it('bad end_time format → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 't', time: '09:00', end_time: '25:99' });
expect(res.status).toBe(400);
});
it('single-day end_time before start time → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 't', time: '10:00', end_time: '09:00' });
expect(res.status).toBe(400);
});
it('multi-day allows end_time earlier than start time (end is on a later day)', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', end_date: '2026-07-03', title: 'trip', time: '18:00', end_time: '09:00' });
expect(res.status).toBe(201);
expect(res.body.endTime).toBe('09:00');
});
it('PATCH start time only → 400 when it inverts an existing single-day range', async () => {
// 09:0010:00 single-day, then move start to 11:00 (end_time omitted) → 11:00 > 10:00 invalid.
const created = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 'x', time: '09:00', end_time: '10:00' });
const res = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
.send({ time: '11:00' });
expect(res.status).toBe(400);
});
it('PATCH end_date="" → 400 when collapsing a multi-day event leaves end before start', async () => {
// 18:00 → 09:00 across days is valid; collapsing to single day makes 09:00 < 18:00 invalid.
const created = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', end_date: '2026-07-03', title: 'trip', time: '18:00', end_time: '09:00' });
const res = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
.send({ end_date: '' });
expect(res.status).toBe(400);
});
it('PATCH sets and clears end_time', async () => {
const created = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 'x', time: '09:00' });
const set = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
.send({ end_time: '10:00' });
expect(set.status).toBe(200);
expect(set.body.endTime).toBe('10:00');
const cleared = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
.send({ end_time: '' });
expect(cleared.status).toBe(200);
expect(cleared.body.endTime).toBeNull();
});
});
describe('multi-day events via HTTP', () => {
it('creates a span and surfaces it on every covered day in the month view', async () => {
const created = await request(appAs(owner))

View File

@ -394,8 +394,8 @@ describe('space-api', () => {
expect(res.status).toBe(404);
});
it('does not delete a directory (skips it, leaving it intact)', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'dir-skip' })).body;
it('deletes a user-created directory recursively (folder delete)', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'dir-del' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
mkdirSync(join(filesDir, 'sub'), { recursive: true });
writeFileSync(join(filesDir, 'sub', 'f.txt'), 'x');
@ -404,8 +404,25 @@ describe('space-api', () => {
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ paths: ['sub'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual(['sub']);
expect(res.body.skipped).toEqual([]);
expect(existsSync(join(filesDir, 'sub'))).toBe(false);
});
it('does not delete a structural workspace dir (skips it, leaving it intact)', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'dir-protect' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
// `output` is a reserved structural dir (isProtectedWorkspaceDir) and must
// survive an API-direct delete, since the UI guard alone can be bypassed.
mkdirSync(join(filesDir, 'output'), { recursive: true });
writeFileSync(join(filesDir, 'output', 'f.txt'), 'x');
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ paths: ['output'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual([]);
expect(res.body.skipped).toEqual(['sub']);
expect(existsSync(join(filesDir, 'sub', 'f.txt'))).toBe(true);
expect(res.body.skipped).toEqual(['output']);
expect(existsSync(join(filesDir, 'output', 'f.txt'))).toBe(true);
});
});

View File

@ -0,0 +1,220 @@
/**
* APIS-038 Space tool-policy sensitive-tool write round-trip (PR #653 regression class).
*
* The PR #653 save-loss bug was caused by the policy write path walking only the
* CATEGORY system while the separate `sensitiveTools` channel (SENSITIVE_TOOLS,
* e.g. Bash) was delivered via a different field. A regression here silently
* grants or denies Bash/SSH/browser. The existing space-api.tool-policy.test.ts
* covers the manager/member/stranger authz matrix and a single Bash+ssh PUT;
* this file deepens the DUAL-CHANNEL persistence guarantee:
*
* - a sensitive TOOL (Bash, separate `sensitiveTools` array) AND
* - sensitive CATEGORIES (ssh, browser, the `categories` array)
*
* must BOTH round-trip through PUT persist GET independently and together,
* and a non-manager must never be able to write either channel.
*
* Auth pattern: authActive=true + middleware injecting req.user (Passport sim).
* Roles: manager (space owner), member (viewer-role), stranger (no relation).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { createSpaceApi } from './space-api.js';
import { SENSITIVE_CATEGORIES, SENSITIVE_TOOLS } from '../engine/tools/tool-categories.js';
function freshSetup() {
const dir = mkdtempSync(join(tmpdir(), 'maestro-toolpolicy-sens-'));
const repo = new Repository(join(dir, `${randomUUID()}.db`));
return { repo, dir };
}
function makeApp(repo: Repository, currentUser: Express.User) {
const app = express();
app.use((req: any, _res: any, next: any) => {
req.user = currentUser;
next();
});
app.use(
'/',
createSpaceApi({
repo,
dataRoot: tmpdir(),
worktreeDir: tmpdir(),
authActive: true,
}),
);
return app;
}
async function seed(repo: Repository) {
const manager = repo.createUser({ email: 'mgr@x.com', name: 'Mgr', role: 'user', status: 'active' });
const member = repo.createUser({ email: 'mem@x.com', name: 'Mem', role: 'user', status: 'active' });
const stranger = repo.createUser({ email: 'str@x.com', name: 'Str', role: 'user', status: 'active' });
// Public so member/stranger can GET (viewer-level read of the policy).
const space = await repo.createSpace({ kind: 'case', title: 'TP Space', ownerId: manager.id, visibility: 'public' });
await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer', invitedBy: manager.id });
return { manager, member, stranger, space };
}
function asUser(u: { id: string; role: 'admin' | 'user' }): Express.User {
return { id: u.id, role: u.role, orgIds: [] } as unknown as Express.User;
}
/** Pull a sensitive TOOL entry (separate channel). Asserts the fixture is meaningful. */
function bashName(): string {
const name = [...SENSITIVE_TOOLS][0];
expect(name).toBeDefined();
return name as string;
}
/** Pull the sensitive CATEGORY names (category channel). */
function sensitiveCategoryNames(): string[] {
return [...SENSITIVE_CATEGORIES];
}
describe('APIS-038 tool-policy sensitive dual-channel round-trip', () => {
let repo: Repository;
let dir: string;
let fx: Awaited<ReturnType<typeof seed>>;
beforeEach(async () => {
({ repo, dir } = freshSetup());
fx = await seed(repo);
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
it('manager enabling ONLY a sensitive TOOL (Bash) persists via the sensitiveTools channel', async () => {
const app = makeApp(repo, asUser(fx.manager));
const bash = bashName();
const put = await request(app)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: [bash] });
expect(put.status).toBe(200);
// The persisted JSON keeps the tool in enabledSensitive.
expect(put.body.policy.enabledSensitive).toContain(bash);
// Re-read from a fresh GET (proves it round-tripped through the DB, not echo).
const get = await request(app).get(`/${fx.space.id}/tool-policy`);
expect(get.status).toBe(200);
// Channel 1: sensitiveTools array shows Bash enabled=true.
const bashEntry = get.body.sensitiveTools.find((t: any) => t.name === bash);
expect(bashEntry).toBeDefined();
expect(bashEntry.enabled).toBe(true);
// Channel 2 (categories) untouched: every sensitive category stays OFF.
for (const catName of sensitiveCategoryNames()) {
const cat = get.body.categories.find((c: any) => c.name === catName);
expect(cat, catName).toBeDefined();
expect(cat.enabled, `${catName} should stay OFF`).toBe(false);
}
});
it('manager enabling ONLY sensitive CATEGORIES persists via the categories channel (Bash stays OFF)', async () => {
const app = makeApp(repo, asUser(fx.manager));
const cats = sensitiveCategoryNames();
const bash = bashName();
const put = await request(app)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: cats });
expect(put.status).toBe(200);
const get = await request(app).get(`/${fx.space.id}/tool-policy`);
expect(get.status).toBe(200);
// Channel 2: every sensitive category now enabled.
for (const catName of cats) {
const cat = get.body.categories.find((c: any) => c.name === catName);
expect(cat, catName).toBeDefined();
expect(cat.enabled, `${catName} should be ON`).toBe(true);
}
// Channel 1 untouched: Bash stays OFF (the separate-channel bug class).
const bashEntry = get.body.sensitiveTools.find((t: any) => t.name === bash);
expect(bashEntry.enabled).toBe(false);
});
it('manager enabling BOTH channels together persists both (no cross-channel clobber)', async () => {
const app = makeApp(repo, asUser(fx.manager));
const cats = sensitiveCategoryNames();
const bash = bashName();
const put = await request(app)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: [bash, ...cats] });
expect(put.status).toBe(200);
const get = await request(app).get(`/${fx.space.id}/tool-policy`);
// Tool channel enabled.
expect(get.body.sensitiveTools.find((t: any) => t.name === bash).enabled).toBe(true);
// Category channel enabled.
for (const catName of cats) {
expect(get.body.categories.find((c: any) => c.name === catName).enabled, catName).toBe(true);
}
});
it('toggling a sensitive tool OFF again clears it from BOTH the persisted JSON and the GET view', async () => {
const app = makeApp(repo, asUser(fx.manager));
const bash = bashName();
// Turn it on.
await request(app)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: [bash] });
// Turn it off (empty enabledSensitive).
const off = await request(app)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: [] });
expect(off.status).toBe(200);
// Empty arrays are dropped from the clean policy.
expect(off.body.policy.enabledSensitive).toBeUndefined();
const get = await request(app).get(`/${fx.space.id}/tool-policy`);
expect(get.body.sensitiveTools.find((t: any) => t.name === bash).enabled).toBe(false);
});
it('member (viewer) cannot write the sensitive-tool channel — 403, no persistence', async () => {
const bash = bashName();
const memberApp = makeApp(repo, asUser(fx.member));
const res = await request(memberApp)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: [bash] });
expect(res.status).toBe(403);
// Manager re-reads: Bash must still be OFF (the rejected write left no trace).
const managerApp = makeApp(repo, asUser(fx.manager));
const get = await request(managerApp).get(`/${fx.space.id}/tool-policy`);
expect(get.body.sensitiveTools.find((t: any) => t.name === bash).enabled).toBe(false);
});
it('stranger on a public space cannot write the sensitive channel — 403', async () => {
const bash = bashName();
const app = makeApp(repo, asUser(fx.stranger));
const res = await request(app)
.put(`/${fx.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: [bash] });
expect(res.status).toBe(403);
});
});

View File

@ -0,0 +1,295 @@
/**
* Multi-user integration tests for GET/PUT /api/local/spaces/:id/tool-policy.
*
* Auth pattern: authActive=true + middleware that sets req.user = <seeded user>.
* Roles: manager (space owner), member (viewer-role member), stranger (no relation).
*
* Space visibility is 'public' so GET succeeds for viewer and member without
* needing org membership consistent with how other space-api viewer routes work.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { createSpaceApi } from './space-api.js';
import { SENSITIVE_CATEGORIES, SENSITIVE_TOOLS } from '../engine/tools/tool-categories.js';
// ─── Test fixtures ──────────────────────────────────────────────────────────
function freshSetup() {
const dir = mkdtempSync(join(tmpdir(), 'tool-policy-test-'));
const repo = new Repository(join(dir, `${randomUUID()}.db`));
return { repo, dir };
}
function makeApp(repo: Repository, currentUser: Express.User) {
const app = express();
// Inject the current user (simulating Passport session middleware).
app.use((req: any, _res: any, next: any) => {
req.user = currentUser;
next();
});
app.use(
'/',
createSpaceApi({
repo,
dataRoot: tmpdir(),
worktreeDir: tmpdir(),
authActive: true, // enables viewerOf to read req.user
}),
);
return app;
}
// ─── Seed helpers ───────────────────────────────────────────────────────────
async function seed(repo: Repository) {
const manager = repo.createUser({
email: 'manager@example.com',
name: 'Manager',
role: 'user',
status: 'active',
});
const member = repo.createUser({
email: 'member@example.com',
name: 'Member',
role: 'user',
status: 'active',
});
const stranger = repo.createUser({
email: 'stranger@example.com',
name: 'Stranger',
role: 'user',
status: 'active',
});
// Public space so member and stranger can GET (viewer reads the policy)
const space = await repo.createSpace({
kind: 'case',
title: 'Test Space',
ownerId: manager.id,
visibility: 'public',
});
// Add member as viewer-role member
await repo.addSpaceMember({
spaceId: space.id,
userId: member.id,
role: 'viewer',
invitedBy: manager.id,
});
return { manager, member, stranger, space };
}
function asExpressUser(user: { id: string; role: 'admin' | 'user' }): Express.User {
return { id: user.id, role: user.role, orgIds: [] } as unknown as Express.User;
}
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('GET /api/local/spaces/:id/tool-policy', () => {
let repo: Repository;
let dir: string;
let fixtures: Awaited<ReturnType<typeof seed>>;
beforeEach(async () => {
({ repo, dir } = freshSetup());
fixtures = await seed(repo);
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
it('manager (owner) receives 200 with categories + policy shape', async () => {
const app = makeApp(repo, asExpressUser(fixtures.manager));
const res = await request(app).get(`/${fixtures.space.id}/tool-policy`);
expect(res.status).toBe(200);
// policy shape
expect(res.body).toHaveProperty('policy');
expect(res.body.policy).toEqual({}); // null policy → empty object
// categories array
expect(res.body).toHaveProperty('categories');
expect(Array.isArray(res.body.categories)).toBe(true);
expect(res.body.categories.length).toBeGreaterThan(0);
for (const cat of res.body.categories) {
expect(cat).toHaveProperty('name');
expect(cat).toHaveProperty('sensitive');
expect(cat).toHaveProperty('enabled');
expect(typeof cat.name).toBe('string');
expect(typeof cat.sensitive).toBe('boolean');
expect(typeof cat.enabled).toBe('boolean');
// safe categories default ON; sensitive categories default OFF
if (SENSITIVE_CATEGORIES.has(cat.name)) {
expect(cat.sensitive).toBe(true);
expect(cat.enabled).toBe(false);
} else {
expect(cat.sensitive).toBe(false);
expect(cat.enabled).toBe(true);
}
}
// sensitiveTools array
expect(res.body).toHaveProperty('sensitiveTools');
expect(Array.isArray(res.body.sensitiveTools)).toBe(true);
for (const t of res.body.sensitiveTools) {
expect(SENSITIVE_TOOLS.has(t.name)).toBe(true);
expect(t.enabled).toBe(false); // default OFF
}
});
it('member (viewer-role) receives 200', async () => {
const app = makeApp(repo, asExpressUser(fixtures.member));
const res = await request(app).get(`/${fixtures.space.id}/tool-policy`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('policy');
expect(res.body).toHaveProperty('categories');
});
it('stranger cannot see a private space (404)', async () => {
// Create a private space and verify stranger gets 404
const priv = await repo.createSpace({
kind: 'case',
title: 'Private',
ownerId: fixtures.manager.id,
visibility: 'private',
});
const app = makeApp(repo, asExpressUser(fixtures.stranger));
const res = await request(app).get(`/${priv.id}/tool-policy`);
expect(res.status).toBe(404);
});
it('public space: stranger receives 200 (viewer-level read)', async () => {
const app = makeApp(repo, asExpressUser(fixtures.stranger));
const res = await request(app).get(`/${fixtures.space.id}/tool-policy`);
expect(res.status).toBe(200);
});
});
describe('PUT /api/local/spaces/:id/tool-policy', () => {
let repo: Repository;
let dir: string;
let fixtures: Awaited<ReturnType<typeof seed>>;
beforeEach(async () => {
({ repo, dir } = freshSetup());
fixtures = await seed(repo);
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
it('manager (owner) can PUT and GET reflects the change', async () => {
const app = makeApp(repo, asExpressUser(fixtures.manager));
const body = { disabledSafe: ['core'], enabledSensitive: ['Bash', 'ssh'] };
const put = await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send(body);
expect(put.status).toBe(200);
// Returned shape same as GET
expect(put.body).toHaveProperty('policy');
expect(put.body.policy.disabledSafe).toEqual(['core']);
expect(put.body.policy.enabledSensitive).toContain('Bash');
expect(put.body.policy.enabledSensitive).toContain('ssh');
// Subsequent GET reflects persisted change
const get = await request(app).get(`/${fixtures.space.id}/tool-policy`);
expect(get.status).toBe(200);
expect(get.body.policy.disabledSafe).toEqual(['core']);
expect(get.body.policy.enabledSensitive).toContain('Bash');
// Bash should now show enabled=true in sensitiveTools
const bashEntry = get.body.sensitiveTools.find((t: any) => t.name === 'Bash');
expect(bashEntry).toBeDefined();
expect(bashEntry.enabled).toBe(true);
// ssh category should now show enabled=true
const sshCat = get.body.categories.find((c: any) => c.name === 'ssh');
expect(sshCat).toBeDefined();
expect(sshCat.enabled).toBe(true);
});
it('member (viewer-role member) receives 403 on PUT', async () => {
const app = makeApp(repo, asExpressUser(fixtures.member));
const res = await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: ['Bash'] });
expect(res.status).toBe(403);
});
it('stranger on a private space receives 404 on PUT', async () => {
const priv = await repo.createSpace({
kind: 'case',
title: 'Private',
ownerId: fixtures.manager.id,
visibility: 'private',
});
const app = makeApp(repo, asExpressUser(fixtures.stranger));
const res = await request(app)
.put(`/${priv.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: ['Bash'] });
expect(res.status).toBe(404);
});
it('stranger on a public space receives 403 on PUT', async () => {
const app = makeApp(repo, asExpressUser(fixtures.stranger));
const res = await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: ['Bash'] });
expect(res.status).toBe(403);
});
it('PUT with unknown category/tool name returns 400', async () => {
const app = makeApp(repo, asExpressUser(fixtures.manager));
const res = await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: ['Bash', 'nonexistent-tool-xyz'] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/unknown/i);
});
it('PUT with non-array fields returns 400', async () => {
const app = makeApp(repo, asExpressUser(fixtures.manager));
const res = await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ disabledSafe: 'not-an-array' });
expect(res.status).toBe(400);
});
it('PUT with empty body clears the policy', async () => {
// First set something
const app = makeApp(repo, asExpressUser(fixtures.manager));
await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({ enabledSensitive: ['Bash'] });
// Then clear
const res = await request(app)
.put(`/${fixtures.space.id}/tool-policy`)
.set('Content-Type', 'application/json')
.send({});
expect(res.status).toBe(200);
expect(res.body.policy).toEqual({});
});
});

View File

@ -1,22 +1,23 @@
import express, { Router } from 'express';
import { dirname, join, extname, basename, relative } from 'node:path';
import { mkdirSync, readdirSync, statSync, readFileSync, openSync, writeSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
import AdmZip from 'adm-zip';
import { dirname } from 'node:path';
import type { Repository } from '../db/repository.js';
import { spaceWorkspaceDir, spaceFilesDir } from '../spaces/paths.js';
import { spaceWorkspaceDir } from '../spaces/paths.js';
import { scaffoldCaseSpace, removeSpaceDirs } from '../spaces/scaffold.js';
import { canManageSpace, canEditInSpace } from './visibility.js';
import type { SpaceMemberRoleValue, SpaceInvite, SpaceInviteRole } from '../db/repository.js';
import {
ensurePathWithin,
isPathEscapeError,
isNotFoundError,
serializeLocalFileEntry,
setUntrustedFileResponseHeaders,
safeZipEntryName,
} from './local-api-helpers.js';
import { canManageSpace } from './visibility.js';
import { registerSpaceFilesRoutes } from './space-files-api.js';
import { registerSpaceMembersRoutes } from './space-members-api.js';
import { registerSpaceCalendarRoutes } from './space-calendar-api.js';
import { registerSpaceInviteRoutes } from './space-invite-api.js';
import { registerSpaceAppShareRoutes } from './space-app-share-api.js';
import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js';
import { viewerOf } from './space-viewer.js';
import { logger } from '../logger.js';
// cross-calendar-api.ts は parseTzOffset / monthBounds を space-api 経由で参照する。
// 実体はカレンダールートと同居させたほうが凝集が高いので space-calendar-api に移し、
// 後方互換のためここで再エクスポートするspaceViewerOf と同じ公開境界の扱い)。
export { parseTzOffset, monthBounds } from './space-calendar-api.js';
export interface SpaceApiDeps {
repo: Repository;
dataRoot: string;
@ -26,22 +27,6 @@ export interface SpaceApiDeps {
uploadLimitMb?: number;
}
/**
* OFF synthetic 'local' no-auth local
* role 'admin' server.ts no-auth
* deserializeUser `{ id: 'local', role: 'admin' }`
* memory-api / reflection-api role:'user' owner_id
* role owner_id=null local
* canEditEntity role:'admin' owner
* 'user' owner_id=null
*/
function viewerOf(req: any, authActive: boolean): Express.User {
if (authActive && req.user) return req.user;
return req.user ?? ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User);
}
const MAX_TZ_OFFSET = 14 * 60; // ±14h は IANA TZ の理論上限
/**
* OFF cross-calendar-api
* viewerOf synthetic 'local' admin
@ -50,71 +35,6 @@ export function spaceViewerOf(req: any, authActive: boolean): Express.User {
return viewerOf(req, authActive);
}
/** tz_offset クエリ(分、-getTimezoneOffset() 由来で JST=+540を安全に整数化する。 */
export function parseTzOffset(raw: unknown): number {
const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(n)) return 0;
return Math.max(-MAX_TZ_OFFSET, Math.min(MAX_TZ_OFFSET, n));
}
/** 'YYYY-MM' の月初・月末(ローカル暦日)を返す。 */
export function monthBounds(month: string): { monthStart: string; monthEnd: string } {
const [y, m] = month.split('-').map((s) => parseInt(s, 10));
const start = `${month}-01`;
// 当月末日 = 翌月0日Date は UTC ベースだが日付計算のみに使うので TZ 非依存)。
const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate();
const end = `${month}-${String(lastDay).padStart(2, '0')}`;
return { monthStart: start, monthEnd: end };
}
/** UTC instantmsをローカル TZ オフセット分ずらして暦日 'YYYY-MM-DD' に落とす。 */
function localDayOfMs(ms: number, tzOffsetMin: number): string {
return new Date(ms + tzOffsetMin * 60_000).toISOString().slice(0, 10);
}
/**
* spaceFilesDir mtime date
* runs/.conflict/
*
*/
function scanFilesForLocalDay(
rootDir: string,
date: string,
tzOffsetMin: number,
): Array<{ name: string; path: string; size: number; mtime: string }> {
const out: Array<{ name: string; path: string; size: number; mtime: string }> = [];
const walk = (absDir: string, relDir: string): void => {
let entries: import('node:fs').Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return; // ディレクトリ非存在・読めない場合は黙ってスキップ
}
for (const entry of entries) {
// runs/(実行ログ)・.conflict・全ドットファイルを除外
if (entry.name === 'runs' || entry.name === '.conflict' || entry.name.startsWith('.')) continue;
const abs = join(absDir, entry.name);
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
continue;
}
if (!entry.isFile()) continue;
let stat;
try {
stat = statSync(abs);
} catch {
continue;
}
if (localDayOfMs(stat.mtimeMs, tzOffsetMin) === date) {
out.push({ name: entry.name, path: rel, size: stat.size, mtime: stat.mtime.toISOString() });
}
}
};
walk(rootDir, '');
return out;
}
export function createSpaceApi(deps: SpaceApiDeps): Router {
const router = Router();
const { repo, dataRoot, worktreeDir } = deps;
@ -209,690 +129,23 @@ export function createSpaceApi(deps: SpaceApiDeps): Router {
res.json({ ok: true });
});
// ─── ファイル窓(スペースの永続ワークスペース {worktreeDir}/space/{id}/files────────
//
// タスク版local-files-api.tsの section='space' に相当する経路を、スペース id で
// キーした形で提供する。閉じ込めは spaceFilesDir(worktreeDir, id) を root にした
// ensurePathWithinrealpath ではなく resolve ベースの正規化ガード。task の files API と同一)で行う。
// 可視性は getSpace({viewer}) が null を返したら 404owner/org/public の判定を repo に委譲)。
// Space ファイル窓ルート(/:id/files/*)は space-files-api.ts に分離(巨大関数分割)。
registerSpaceFilesRoutes(router, deps);
// 一覧
router.get('/:id/files', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const rootDir = spaceFilesDir(worktreeDir, space.id);
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true })
// 計画5 (Phase C): 成果物だけの綺麗なツリーにするため、実行ログ (logs)・
// 競合台帳 (.conflict)・全ドットファイルをファイル窓から除外する。
.filter((entry) => entry.name !== 'logs' && entry.name !== '.conflict' && !entry.name.startsWith('.'))
.map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: 'space', path: relativeDir, entries });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] files list not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] files list error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
// カレンダー(予定 + 日次集計)は space-calendar-api.ts に分離(巨大関数分割)。
registerSpaceCalendarRoutes(router, deps);
// テキスト本文(プレビュー用)
router.get('/:id/files/content', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] file content not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] file content error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// メンバー協働者CRUD は space-members-api.ts に分離(巨大関数分割)。
registerSpaceMembersRoutes(router, deps);
// バイナリ配信 / HTML アプリ実行trusted=1
router.get('/:id/files/raw', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
// trusted=1 で CSP sandbox を外し、生成 HTML をアプリ origin 上でアプリとして実行させる。
// 信頼モデルは「スペースのオーナーは信頼できる同僚」(社内 Gitea-org ツール)。
// 手前の getSpace({viewer}) で可視性private=owner+admin / org / publicを通過した
// 閲覧者には trusted 版を配る。受容するトレードオフ: オーナーの無サンドボックス HTML は
// 閲覧者のセッションで動くため、悪意あるオーナーは閲覧者になりすませる(オーナーは信頼前提)。
// ハードフロア: 未認証(実 req.user なし)には trusted を渡さない=共有リンクはまず
// ログインを経由する。viewerOf は authActive 時に synthetic admin を返しうるので、
// ここは viewer ではなく実認証ユーザー (req.user) の有無で判定する。
// 認証 OFF の単一運用者モードでは唯一の主体が全スペースの ownerself-XSS のみ)。
const trustedAllowed = deps.authActive ? !!(req as { user?: Express.User }).user : true;
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
if (!trustedHtml) setUntrustedFileResponseHeaders(res);
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] file raw not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] file raw error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
// 招待リンク(再利用トークン)は space-invite-api.ts に分離(巨大関数分割)。
registerSpaceInviteRoutes(router, deps);
// アップロードowner / admin / member.role∈{owner,editor})。共有スペースは協働モデルなので
// 行 owner 限定にはしないが、viewer ロールのメンバーは read のみで書き込み不可。
// base64-JSON 方式添付と同形、multipart dep 無し)。同名衝突は `{stem} (N){ext}` に
// O_EXCL で確保してリネーム上書き禁止。path は ensurePathWithin で spaceFilesDir に封じ込め。
router.post('/:id/files/upload', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
// 公開アプリ共有リンクは space-app-share-api.ts に分離(巨大関数分割)。
registerSpaceAppShareRoutes(router, deps);
const files = req.body?.files;
if (!Array.isArray(files) || files.length === 0) {
return res.status(400).json({ error: 'files must be a non-empty array' });
}
const reqPath = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const filesDir = spaceFilesDir(worktreeDir, space.id);
const uploaded: { name: string; path: string }[] = [];
for (const file of files) {
const safeName = String(file?.name ?? '').replace(/[\\/]/g, '_');
if (!safeName) return res.status(400).json({ error: 'file name is required' });
const rel = join(reqPath, safeName);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
mkdirSync(dirname(abs), { recursive: true });
const buf = Buffer.from(String(file?.contentBase64 ?? ''), 'base64');
// 衝突回避: 既存なら `{stem} (N){ext}` を O_EXCL で予約して書く(上書き禁止)。
const ext = extname(safeName);
const stem = basename(safeName, ext);
let candidateName = safeName;
let candidateAbs = abs;
let fd: number | undefined;
for (let n = 1; ; n++) {
if (n > 1) {
candidateName = `${stem} (${n})${ext}`;
candidateAbs = ensurePathWithin(filesDir, join(reqPath, candidateName));
}
try {
fd = openSync(candidateAbs, 'wx'); // O_EXCL: 既存なら EEXIST
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
throw e;
}
}
try {
writeSync(fd, buf);
} finally {
closeSync(fd);
}
const writtenRel = reqPath ? `${reqPath}/${candidateName}` : candidateName;
uploaded.push({ name: candidateName, path: writtenRel });
}
res.json({ uploaded });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file upload error: ${err}`);
res.status(500).json({ error: 'Failed to upload files' });
}
});
// 削除owner / admin / member.role∈{owner,editor}。viewer ロールは read のみで削除不可。
// body は { paths: string[] }(複数選択)または単一 { path: string }。各パスは
// ensurePathWithin で spaceFilesDir に封じ込めtraversal は throw → 400、ファイルのみ
// 対象ディレクトリはスキップskipped に記録、ファイル窓を壊さない)。存在しないパスは
// 黙ってスキップ(冪等)。削除結果は { deleted, skipped } で返す。
router.post('/:id/files/delete', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const deleted: string[] = [];
const skipped: string[] = [];
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) { skipped.push(String(raw ?? '')); continue; }
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
let stat;
try {
stat = statSync(abs);
} catch {
skipped.push(rel); // 存在しない → 冪等にスキップ
continue;
}
if (!stat.isFile()) { skipped.push(rel); continue; } // ディレクトリ等は対象外
unlinkSync(abs);
deleted.push(rel);
}
res.json({ deleted, skipped });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file delete error: ${err}`);
res.status(500).json({ error: 'Failed to delete files' });
}
});
// 複数ファイルを zip でダウンロード。ダウンロードは閲覧操作なので read ゲート
// getSpace が viewer で読めれば可。canEditInSpace は不要)。各 path は spaceFilesDir に
// 封じ込めtraversal は 400。ファイルのみ、ディレクトリ/不在はスキップ。zip エントリ名は
// 相対パス(絶対パスを漏らさない)。
router.post('/:id/files/download-zip', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const zip = new AdmZip();
let added = 0;
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) continue;
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
let stat;
try { stat = statSync(abs); } catch { continue; } // 不在はスキップ
if (!stat.isFile()) continue; // ディレクトリ等は対象外
// zip エントリ名は封じ込め済み abs から安全に再生成zip-slip 対策、共有ヘルパ)。
const entryName = safeZipEntryName(filesDir, abs);
if (!entryName) continue;
zip.addFile(entryName, readFileSync(abs));
added++;
}
if (added === 0) return res.status(404).json({ error: 'no downloadable files' });
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="files.zip"');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.send(zip.toBuffer());
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file zip error: ${err}`);
res.status(500).json({ error: 'Failed to build zip' });
}
});
// プログラム書込1 ファイルを path 指定で上書き。upload が O_EXCL で衝突回避リネーム
// するのに対し、こちらは「同じパスを更新する」用途(ワークスペース・アプリの postMessage
// ブリッジ writeFile 等)のため**上書き許可**。本文は base64任意バイナリまたは content
// UTF-8 テキスト)で受ける。書込ゲートは upload/delete と同じ canEditInSpaceowner /
// admin / member.role∈{owner,editor}。path は ensurePathWithin で spaceFilesDir に封じ込め
// traversal は throw → 400。親ディレクトリは mkdir -p。
router.post('/:id/files/write', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rel = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!rel) return res.status(400).json({ error: 'path is required' });
const hasBase64 = typeof req.body?.contentBase64 === 'string';
const hasText = typeof req.body?.content === 'string';
if (!hasBase64 && !hasText) {
return res.status(400).json({ error: 'content or contentBase64 is required' });
}
const buf = hasBase64
? Buffer.from(String(req.body.contentBase64), 'base64')
: Buffer.from(String(req.body.content), 'utf-8');
// サイズ上限DoS / ディスク枯渇対策)。
const MAX_WRITE_BYTES = 10 * 1024 * 1024;
if (buf.length > MAX_WRITE_BYTES) {
return res.status(400).json({ error: `content exceeds ${MAX_WRITE_BYTES} bytes` });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
// 書込先は apps/ と output/ サブツリーに限定する。任意 .html や AGENTS.md /
// source/index.jsonl 等を上書きされ、trusted-html 無サンドボックス配信経路と
// 組み合わさって stored XSS になるのを防ぐ(ブリッジ writeFile の正当な用途は
// アプリの出力 = output/ と アプリ自身のデータ = apps/。escape は ensurePathWithin
// が既に弾く。正規化後の相対先頭セグメントで判定する。
const normRel = relative(filesDir, abs).split(/[\\/]/);
if (normRel[0] !== 'apps' && normRel[0] !== 'output') {
return res.status(403).json({ error: 'write target must be under apps/ or output/' });
}
// 既存ディレクトリを潰さないガード(ファイルのみ上書き対象)。
try {
if (statSync(abs).isDirectory()) {
return res.status(400).json({ error: 'path points to a directory' });
}
} catch { /* 非存在は新規作成 */ }
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, buf);
res.json({ path: rel, bytes: buf.length });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file write error: ${err}`);
res.status(500).json({ error: 'Failed to write file' });
}
});
// ─── カレンダー(予定 + 日次集計)────────────────────────────────────
//
// 可視性は他のスペース配下ルートと同じく getSpace({viewer}) が null を返したら
// 404。書き込みPOST/PATCH/DELETEは canEditInSpaceowner / admin / member.role∈
// {owner,editor}で更にゲート。viewer ロールのメンバーは閲覧のみで編集不可。
// 他スペースのイベント id への操作は spaceId 不一致で 404buildVisibilityWhere
// 同様のスコープ)。日付バケツ化のローカル TZ オフセットは tz_offsetで受ける
// Usage ダッシュボードと同方式)。
// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md
// 月ビュー: 日別カウント + 当月の予定一覧
router.get('/:id/calendar', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const month = String(req.query.month ?? '');
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ error: 'month must be YYYY-MM' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const { monthStart, monthEnd } = monthBounds(month);
// Personal spaces also own the owner's space-less (space_id NULL) tasks.
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
const result = await repo.getSpaceCalendarMonth(space.id, { monthStart, monthEnd, tzOffsetMin, personalOwnerId });
res.json(result);
});
// 日詳細: その日に作成されたタスク / 変更ファイル / 予定
router.get('/:id/calendar/day', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const date = String(req.query.date ?? '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
const { tasks, events } = await repo.getSpaceCalendarDay(space.id, date, tzOffsetMin, personalOwnerId);
const files = scanFilesForLocalDay(spaceFilesDir(worktreeDir, space.id), date, tzOffsetMin);
res.json({ tasks, files, events });
});
// 予定の作成(編集権限保有者のみ)
router.post('/:id/calendar/events', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const date = (req.body?.date ?? '').toString();
const title = (req.body?.title ?? '').toString().trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
if (!title) return res.status(400).json({ error: 'title is required' });
const time = req.body?.time != null && req.body.time !== '' ? String(req.body.time) : null;
if (time != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) {
return res.status(400).json({ error: 'time must be HH:MM' });
}
let endDate: string | null = null;
if (req.body?.end_date != null && req.body.end_date !== '') {
endDate = String(req.body.end_date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
if (endDate < date) return res.status(400).json({ error: 'end_date must be on or after date' });
}
const ownerId = viewer.id === 'local' ? null : viewer.id;
const ev = await repo.createCalendarEvent({
spaceId: space.id,
ownerId,
date,
endDate,
time,
title,
description: req.body?.description != null ? String(req.body.description) : null,
createdBy: 'user',
});
res.status(201).json(ev);
});
// 予定の更新(編集権限保有者のみ、他スペースの id は 404
router.patch('/:id/calendar/events/:eventId', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const eventId = Number(req.params.eventId);
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
const patch: { date?: string; endDate?: string | null; time?: string | null; title?: string; description?: string | null } = {};
if (req.body?.date !== undefined) {
const d = String(req.body.date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
patch.date = d;
}
// 終了日。null/'' で単日に戻す。開始日(更新後の値)より前は 400。
const effectiveStart = patch.date ?? existing.date;
if (req.body?.end_date !== undefined) {
if (req.body.end_date === null || req.body.end_date === '') {
patch.endDate = null;
} else {
const ed = String(req.body.end_date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(ed)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
if (ed < effectiveStart) return res.status(400).json({ error: 'end_date must be on or after date' });
patch.endDate = ed > effectiveStart ? ed : null;
}
} else if (patch.date !== undefined && existing.endDate && existing.endDate < patch.date) {
// 開始日だけを既存終了日より後ろにずらした場合は整合のため単日へ。
patch.endDate = null;
}
if (req.body?.time !== undefined) {
const t = req.body.time === null || req.body.time === '' ? null : String(req.body.time);
if (t != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(t)) return res.status(400).json({ error: 'time must be HH:MM' });
patch.time = t;
}
if (req.body?.title !== undefined) {
const t = String(req.body.title).trim();
if (!t) return res.status(400).json({ error: 'title is required' });
patch.title = t;
}
if (req.body?.description !== undefined) {
patch.description = req.body.description === null ? null : String(req.body.description);
}
const updated = await repo.updateCalendarEvent(eventId, patch);
res.json(updated);
});
// 予定の削除(編集権限保有者のみ、他スペースの id は 404
router.delete('/:id/calendar/events/:eventId', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const eventId = Number(req.params.eventId);
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
await repo.deleteCalendarEvent(eventId);
res.status(204).end();
});
// ─── メンバー(スペースの協働者)─────────────────────────────────────
//
// 可視性は他のスペース配下ルートと同じく getSpace({viewer}) が null を返したら 404。
// 一覧はスペース可視者なら誰でも可read。追加/変更/削除は canManageSpace
// owner / admin / member.role==='owner')。自分自身を抜けるのは管理権限が無くても可。
// owner_id 本人は常に owner として合成して返し、member 行を持つことはできない。
const VALID_MEMBER_ROLES: readonly SpaceMemberRoleValue[] = ['owner', 'editor', 'viewer'];
const isValidRole = (r: unknown): r is SpaceMemberRoleValue =>
typeof r === 'string' && (VALID_MEMBER_ROLES as readonly string[]).includes(r);
// 一覧スペース可視者なら誰でも。owner_id を owner として合成し、member 行を後続。
router.get('/:id/members', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const members = await repo.listSpaceMembers(space.id);
// email は PII。public/org スペースでは getSpace を通る非メンバー閲覧者にも
// メンバー一覧が見えるため、email は「資格者」(admin / owner / メンバー本人) に
// だけ返す。それ以外には name + avatar のみ(メールアドレス収集ベクター対策)。
const viewerEntitled =
viewer.role === 'admin' ||
space.ownerId === viewer.id ||
(await repo.getSpaceMemberRole(space.id, viewer.id)) !== null;
const maskEmail = (email: string | null) => (viewerEntitled ? email : null);
const out: Array<{
userId: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
role: SpaceMemberRoleValue;
isOwner: boolean;
}> = [];
// owner_id を合成 owner として先頭に置く(行が重複しても owner 行が勝つ)。
if (space.ownerId) {
const ownerUser = repo.getUserById(space.ownerId);
out.push({
userId: space.ownerId,
name: ownerUser?.name ?? null,
email: maskEmail(ownerUser?.email ?? null),
avatarUrl: ownerUser?.avatarUrl ?? null,
role: 'owner',
isOwner: true,
});
}
for (const m of members) {
if (space.ownerId && m.userId === space.ownerId) continue; // owner 行が勝つ → de-dupe
out.push({
userId: m.userId,
name: m.name,
email: maskEmail(m.email),
avatarUrl: m.avatarUrl,
role: m.role,
isOwner: false,
});
}
res.json(out);
});
// 追加 / 招待canManageSpace。owner_id 本人は追加不可。未知ユーザー・不正ロールは 400。
router.post('/:id/members', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const userId = (req.body?.userId ?? '').toString();
const role = req.body?.role;
if (!userId) return res.status(400).json({ error: 'userId is required' });
if (space.ownerId && userId === space.ownerId) {
return res.status(400).json({ error: 'user is already the space owner' });
}
if (!isValidRole(role)) return res.status(400).json({ error: 'invalid role' });
if (!repo.getUserById(userId)) return res.status(400).json({ error: 'unknown user' });
await repo.addSpaceMember({ spaceId: space.id, userId, role, invitedBy: viewer.id });
res.status(201).json({ userId, role });
});
// ロール変更canManageSpace。メンバーでなければ 404、不正ロールは 400。
router.patch('/:id/members/:userId', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const targetUserId = req.params.userId;
const role = req.body?.role;
if (!isValidRole(role)) return res.status(400).json({ error: 'invalid role' });
if (repo.getSpaceMemberRole(space.id, targetUserId) === null) {
return res.status(404).json({ error: 'member not found' });
}
await repo.updateSpaceMemberRole(space.id, targetUserId, role);
res.json({ userId: targetUserId, role });
});
// 除去canManageSpace、または自分自身を抜ける場合。メンバーでなければ 404。
router.delete('/:id/members/:userId', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const targetUserId = req.params.userId;
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
const isSelfRemoval = targetUserId === viewer.id;
if (!isSelfRemoval && !canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
if (repo.getSpaceMemberRole(space.id, targetUserId) === null) {
return res.status(404).json({ error: 'member not found' });
}
await repo.removeSpaceMember(space.id, targetUserId);
res.status(204).end();
});
// ─── 招待リンク(再利用トークン)───────────────────────────────────
// 組織非依存の招待経路。発行/無効化は canManageSpace、参加は認証済みユーザー。
// no-auth モードでは他ユーザーの概念が無いので invite 系は一律 404。
// spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
const INVITE_ROLES: readonly SpaceInviteRole[] = ['editor', 'viewer'];
const isInviteRole = (r: unknown): r is SpaceInviteRole =>
typeof r === 'string' && (INVITE_ROLES as readonly string[]).includes(r);
// invite を API レスポンス形に整えるurl は相対パス。SPA が origin を前置する)。
const inviteOut = (inv: SpaceInvite) => ({
token: inv.token,
url: `/ui/invite/${inv.token}`,
role: inv.role,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
revokedAt: inv.revokedAt,
valid: repo.isSpaceInviteValid(inv),
});
// 現行リンクを取得canManageSpace。無ければ { invite: null }。
router.get('/:id/invite', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const inv = repo.getSpaceInvite(space.id);
res.json({ invite: inv ? inviteOut(inv) : null });
});
// リンクを (再)生成canManageSpace。body { role, expiresInDays? }。
router.post('/:id/invite', jsonParser, async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const role = req.body?.role ?? 'viewer';
if (!isInviteRole(role)) return res.status(400).json({ error: 'invalid role' });
const rawDays = req.body?.expiresInDays;
let expiresInDays: number | null = null;
if (rawDays != null) {
const n = Number(rawDays);
if (!Number.isInteger(n) || n <= 0) {
return res.status(400).json({ error: 'expiresInDays must be a positive integer' });
}
expiresInDays = n;
}
const inv = repo.createSpaceInvite({ spaceId: space.id, role, createdBy: viewer.id, expiresInDays });
res.status(201).json({ invite: inviteOut(inv) });
});
// リンクを無効化canManageSpace
router.delete('/:id/invite', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
repo.revokeSpaceInvite(space.id);
res.status(204).end();
});
// プレビュー(認証済み・メンバーでなくてよい)。無効/不明トークンは 404 で、
// スペース情報を一切返さない(列挙・情報漏洩対策)。
// ルーティング: '/invite/:token' は1セグメント目が literal 'invite'。'/:id/invite'
// とは排他(スペース id は UUID で 'invite' と衝突しない)。
router.get('/invite/:token', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const inv = repo.getSpaceInviteByToken(req.params.token);
if (!repo.isSpaceInviteValid(inv)) return res.status(404).json({ error: 'invalid invite' });
const space = await repo.getSpace(inv.spaceId, { viewer: { id: 'x', role: 'admin', orgIds: [] } as unknown as Express.User });
if (!space) return res.status(404).json({ error: 'invalid invite' });
res.json({ spaceId: space.id, spaceTitle: space.title, role: inv.role });
});
// 参加(認証済み)。検証 → メンバー追加(冪等)。既メンバー/オーナーも 200。
router.post('/invite/:token/accept', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const inv = repo.getSpaceInviteByToken(req.params.token);
if (!repo.isSpaceInviteValid(inv)) return res.status(404).json({ error: 'invalid invite' });
const space = await repo.getSpace(inv.spaceId, { viewer: { id: 'x', role: 'admin', orgIds: [] } as unknown as Express.User });
if (!space) return res.status(404).json({ error: 'invalid invite' });
// オーナー本人 → 既に最上位権限。メンバー行は作らず成功扱い。
if (space.ownerId && viewer.id === space.ownerId) {
return res.json({ spaceId: space.id, alreadyMember: true });
}
const existing = repo.getSpaceMemberRole(space.id, viewer.id);
if (existing) return res.json({ spaceId: space.id, alreadyMember: true });
await repo.addSpaceMember({ spaceId: space.id, userId: viewer.id, role: inv.role, invitedBy: inv.createdBy });
res.json({ spaceId: space.id, alreadyMember: false });
});
// ツールポリシー GET/PUT は space-tool-policy-api.ts に分離(巨大関数分割)。
registerSpaceToolPolicyRoutes(router, deps);
return router;
}

View File

@ -8,7 +8,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
import AdmZip from 'adm-zip';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@ -212,4 +212,144 @@ describe('space /files/write endpoint', () => {
expect(res.status).toBe(404);
});
});
// ── /files/mkdir + /files/moveBox ライクなフォルダ作成・リネーム・移動)──
describe('mkdir', () => {
const mkdir = (u: Express.User, body: unknown) =>
request(appAs(u)).post(`/api/local/spaces/${spaceId}/files/mkdir`).send(body as object);
it('editor creates a folder; it lands on disk', async () => {
const res = await mkdir(user(editorId), { path: 'notes' });
expect(res.status).toBe(200);
expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'notes'))).toBe(true);
});
it('reserved name readonly is allowed (existing-space backfill, idempotent)', async () => {
expect((await mkdir(user(ownerId), { path: 'readonly' })).status).toBe(200);
expect((await mkdir(user(ownerId), { path: 'readonly' })).status).toBe(200); // idempotent
expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'readonly'))).toBe(true);
});
it('viewer → 403, non-member → 404, traversal → 400, missing path → 400', async () => {
expect((await mkdir(user(viewerId), { path: 'x' })).status).toBe(403);
expect((await mkdir(user(strangerId), { path: 'x' })).status).toBe(404);
expect((await mkdir(user(editorId), { path: '../escape' })).status).toBe(400);
expect((await mkdir(user(editorId), {})).status).toBe(400);
});
it('refuses to create over an existing file → 409', async () => {
const filesDir = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(join(filesDir, 'output'), { recursive: true });
writeFileSync(join(filesDir, 'output', 'a.txt'), 'x');
// mkdir at a path that already exists as a FILE
const res = await mkdir(user(editorId), { path: 'output/a.txt' });
expect(res.status).toBe(409);
});
});
describe('move (rename / move)', () => {
const move = (u: Express.User, body: unknown) =>
request(appAs(u)).post(`/api/local/spaces/${spaceId}/files/move`).send(body as object);
beforeEach(() => {
const filesDir = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(join(filesDir, 'notes'), { recursive: true });
writeFileSync(join(filesDir, 'notes', 'a.txt'), 'alpha');
});
it('renames a file in place', async () => {
const res = await move(user(editorId), { from: 'notes/a.txt', to: 'notes/b.txt' });
expect(res.status).toBe(200);
const filesDir = spaceFilesDir(worktreeDir, spaceId);
expect(existsSync(join(filesDir, 'notes', 'a.txt'))).toBe(false);
expect(existsSync(join(filesDir, 'notes', 'b.txt'))).toBe(true);
});
it('renames a user folder', async () => {
const res = await move(user(editorId), { from: 'notes', to: 'memo' });
expect(res.status).toBe(200);
expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'memo', 'a.txt'))).toBe(true);
});
it('protects structural dirs (input/output/logs/apps/readonly/source/skills) → 400', async () => {
for (const dir of ['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills']) {
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), dir), { recursive: true });
const res = await move(user(editorId), { from: dir, to: `${dir}-renamed` });
expect(res.status, dir).toBe(400);
}
});
it('allows moving / renaming FILES inside structural dirs (only the dir itself is locked)', async () => {
// ヘルプの約束「構造フォルダの中のファイルは自由に整理できる」を守る回帰ガード。
// dddc3aff で先頭セグメント判定にした際、output/x.txt も巻き込んで弾いていた。
const filesDir = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(join(filesDir, 'output'), { recursive: true });
writeFileSync(join(filesDir, 'output', 'report.txt'), 'r');
// rename in place inside output/
const renamed = await move(user(editorId), { from: 'output/report.txt', to: 'output/final.txt' });
expect(renamed.status).toBe(200);
expect(existsSync(join(filesDir, 'output', 'final.txt'))).toBe(true);
// move from output/ to a user folder
const moved = await move(user(editorId), { from: 'output/final.txt', to: 'notes/final.txt' });
expect(moved.status).toBe(200);
expect(existsSync(join(filesDir, 'notes', 'final.txt'))).toBe(true);
expect(existsSync(join(filesDir, 'output', 'final.txt'))).toBe(false);
// structural dir itself is still untouched
expect(existsSync(join(filesDir, 'output'))).toBe(true);
});
it('protection cannot be bypassed with dot-prefixed / non-canonical from', async () => {
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'input'), { recursive: true });
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'output'), { recursive: true });
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'source'), { recursive: true });
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'skills'), { recursive: true });
// resolve 後の実体は input/ なのに生文字列が "/" を含む dot 形。すべて 400 でなければならない。
for (const from of ['./input', 'input/.', './/output', './readonly', './.git', './source', 'skills/.']) {
const res = await move(user(editorId), { from, to: 'pwned' });
expect(res.status, from).toBe(400);
}
// input/ は無傷(リネームされていない)
expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'input'))).toBe(true);
expect(existsSync(join(spaceFilesDir(worktreeDir, spaceId), 'pwned'))).toBe(false);
});
it('cannot move a file ONTO a reserved top-level name (no fake structural dir)', async () => {
// to が root 直下の予約名そのもの → 400。配下への移動は許可。
const res = await move(user(editorId), { from: 'notes/a.txt', to: 'output' });
expect(res.status).toBe(400);
const ok = await move(user(editorId), { from: 'notes/a.txt', to: 'output/a.txt' });
expect(ok.status).toBe(200);
});
it('cannot move a file ONTO the reserved names source / skills (incl. dot form)', async () => {
for (const to of ['source', 'skills', './source', './skills']) {
const res = await move(user(editorId), { from: 'notes/a.txt', to });
expect(res.status, to).toBe(400);
}
// 配下への移動は許可source/x.txt 等)
const ok = await move(user(editorId), { from: 'notes/a.txt', to: 'source/a.txt' });
expect(ok.status).toBe(200);
});
it('auto-renames on collision instead of clobbering', async () => {
writeFileSync(join(spaceFilesDir(worktreeDir, spaceId), 'notes', 'c.txt'), 'keep');
const res = await move(user(editorId), { from: 'notes/a.txt', to: 'notes/c.txt' });
expect(res.status).toBe(200);
expect(res.body.to).toBe('notes/c (2).txt');
// original c.txt untouched
expect(readFileSync(join(spaceFilesDir(worktreeDir, spaceId), 'notes', 'c.txt'), 'utf-8')).toBe('keep');
});
it('refuses to move a folder into itself → 400', async () => {
const res = await move(user(editorId), { from: 'notes', to: 'notes/sub/notes' });
expect(res.status).toBe(400);
});
it('viewer → 403, non-member → 404, traversal → 400, missing args → 400', async () => {
expect((await move(user(viewerId), { from: 'notes/a.txt', to: 'notes/b.txt' })).status).toBe(403);
expect((await move(user(strangerId), { from: 'notes/a.txt', to: 'notes/b.txt' })).status).toBe(404);
expect((await move(user(editorId), { from: 'notes/a.txt', to: '../escape.txt' })).status).toBe(400);
expect((await move(user(editorId), { from: 'notes/a.txt' })).status).toBe(400);
});
});
});

View File

@ -0,0 +1,71 @@
import { Router } from 'express';
import { canManageSpace } from './visibility.js';
import { viewerOf } from './space-viewer.js';
import type { SpaceApiDeps } from './space-api.js';
/** 公開アプリ共有リンクの発行/失効/取得。createSpaceApi から分離(巨大関数分割)。挙動は等価。 */
export function registerSpaceAppShareRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo } = deps;
// ─── 公開アプリ共有リンクread-onlyの発行/失効/取得 ─────────────────
//
// スペース内の 1 ワークスペースアプリapps/{app}/)を、ログイン不要の公開 URL で
// read-only 共有するためのトークン管理。発行/失効/取得は canManageSpaceowner /
// admin / member.role==='owner'。editor/viewer・非メンバーは触れない。公開側の
// 配信は app-share-api.ts認証なしが担う。shareUrl は相対パスSPA が origin 前置)。
// spec: docs/superpowers/specs/2026-06-23-public-app-share-link-design.md
// app 名はパスセグメント。区切り・親参照を含むものは拒否apps/{app}/ 解決の安全性)。
const isSafeAppName = (name: string): boolean =>
!!name && !name.includes('/') && !name.includes('\\') && !name.includes('..');
// 現在のリンク状態を取得canManageSpace。無ければ { token: null }。
router.get('/:id/apps/:app/share', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const appName = req.params.app;
if (!isSafeAppName(appName)) return res.status(400).json({ error: 'invalid app name' });
const link = repo.getAppShareLink(space.id, appName);
if (!link || link.revokedAt) {
// 失効済みリンクは公開アクセス不可なので token を露出しないnull 扱い)。
return res.json({ token: null, revokedAt: link?.revokedAt ?? null });
}
res.json({ token: link.token, shareUrl: `/ui/app/${link.token}`, revokedAt: link.revokedAt });
});
// リンクを発行canManageSpace。未失効リンクがあれば再利用。
router.post('/:id/apps/:app/share', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const appName = req.params.app;
if (!isSafeAppName(appName)) return res.status(400).json({ error: 'invalid app name' });
const createdBy = viewer.id === 'local' ? null : viewer.id;
const { token } = repo.createAppShareLink(space.id, appName, createdBy);
res.status(201).json({ token, shareUrl: `/ui/app/${token}` });
});
// リンクを失効canManageSpace。revoked_at を記録。
router.delete('/:id/apps/:app/share', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const appName = req.params.app;
if (!isSafeAppName(appName)) return res.status(400).json({ error: 'invalid app name' });
repo.revokeAppShareLink(space.id, appName);
res.json({ ok: true });
});
}

View File

@ -0,0 +1,254 @@
import express, { Router } from 'express';
import { join } from 'node:path';
import { readdirSync, statSync } from 'node:fs';
import { spaceFilesDir } from '../spaces/paths.js';
import { canEditInSpace } from './visibility.js';
import { viewerOf } from './space-viewer.js';
import type { SpaceApiDeps } from './space-api.js';
const MAX_TZ_OFFSET = 14 * 60; // ±14h は IANA TZ の理論上限
/** tz_offset クエリ(分、-getTimezoneOffset() 由来で JST=+540を安全に整数化する。 */
export function parseTzOffset(raw: unknown): number {
const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(n)) return 0;
return Math.max(-MAX_TZ_OFFSET, Math.min(MAX_TZ_OFFSET, n));
}
/** 'YYYY-MM' の月初・月末(ローカル暦日)を返す。 */
export function monthBounds(month: string): { monthStart: string; monthEnd: string } {
const [y, m] = month.split('-').map((s) => parseInt(s, 10));
const start = `${month}-01`;
// 当月末日 = 翌月0日Date は UTC ベースだが日付計算のみに使うので TZ 非依存)。
const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate();
const end = `${month}-${String(lastDay).padStart(2, '0')}`;
return { monthStart: start, monthEnd: end };
}
/** UTC instantmsをローカル TZ オフセット分ずらして暦日 'YYYY-MM-DD' に落とす。 */
function localDayOfMs(ms: number, tzOffsetMin: number): string {
return new Date(ms + tzOffsetMin * 60_000).toISOString().slice(0, 10);
}
/**
* spaceFilesDir mtime date
* runs/.conflict/
*
*/
function scanFilesForLocalDay(
rootDir: string,
date: string,
tzOffsetMin: number,
): Array<{ name: string; path: string; size: number; mtime: string }> {
const out: Array<{ name: string; path: string; size: number; mtime: string }> = [];
const walk = (absDir: string, relDir: string): void => {
let entries: import('node:fs').Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return; // ディレクトリ非存在・読めない場合は黙ってスキップ
}
for (const entry of entries) {
// runs/(実行ログ)・.conflict・全ドットファイルを除外
if (entry.name === 'runs' || entry.name === '.conflict' || entry.name.startsWith('.')) continue;
const abs = join(absDir, entry.name);
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
continue;
}
if (!entry.isFile()) continue;
let stat;
try {
stat = statSync(abs);
} catch {
continue;
}
if (localDayOfMs(stat.mtimeMs, tzOffsetMin) === date) {
out.push({ name: entry.name, path: rel, size: stat.size, mtime: stat.mtime.toISOString() });
}
}
};
walk(rootDir, '');
return out;
}
/**
* + createSpaceApi
*
* getSpace({viewer}) null
* 404POST/PATCH/DELETE canEditInSpaceowner / admin / member.role
* {owner,editor}viewer
* id spaceId 404buildVisibilityWhere
* TZ tz_offset
* Usage
* spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md
*/
export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo, worktreeDir } = deps;
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
// 月ビュー: 日別カウント + 当月の予定一覧
router.get('/:id/calendar', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const month = String(req.query.month ?? '');
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ error: 'month must be YYYY-MM' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const { monthStart, monthEnd } = monthBounds(month);
// Personal spaces also own the owner's space-less (space_id NULL) tasks.
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
const result = await repo.getSpaceCalendarMonth(space.id, { monthStart, monthEnd, tzOffsetMin, personalOwnerId });
res.json(result);
});
// 日詳細: その日に作成されたタスク / 変更ファイル / 予定
router.get('/:id/calendar/day', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const date = String(req.query.date ?? '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
const { tasks, events } = await repo.getSpaceCalendarDay(space.id, date, tzOffsetMin, personalOwnerId);
const files = scanFilesForLocalDay(spaceFilesDir(worktreeDir, space.id), date, tzOffsetMin);
res.json({ tasks, files, events });
});
// 予定の作成(編集権限保有者のみ)
router.post('/:id/calendar/events', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const date = (req.body?.date ?? '').toString();
const title = (req.body?.title ?? '').toString().trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
if (!title) return res.status(400).json({ error: 'title is required' });
const time = req.body?.time != null && req.body.time !== '' ? String(req.body.time) : null;
if (time != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) {
return res.status(400).json({ error: 'time must be HH:MM' });
}
let endDate: string | null = null;
if (req.body?.end_date != null && req.body.end_date !== '') {
endDate = String(req.body.end_date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
if (endDate < date) return res.status(400).json({ error: 'end_date must be on or after date' });
}
// 終了時刻。開始時刻があるときのみ持てる。単日は開始以降を強制(複数日は終了日側の時刻なので順序不問)。
const endTime = req.body?.end_time != null && req.body.end_time !== '' ? String(req.body.end_time) : null;
if (endTime != null) {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(endTime)) return res.status(400).json({ error: 'end_time must be HH:MM' });
if (time == null) return res.status(400).json({ error: 'end_time requires a start time' });
const multiDay = endDate != null && endDate > date;
if (!multiDay && endTime < time) return res.status(400).json({ error: 'end_time must be on or after the start time' });
}
const ownerId = viewer.id === 'local' ? null : viewer.id;
const ev = await repo.createCalendarEvent({
spaceId: space.id,
ownerId,
date,
endDate,
time,
endTime,
title,
description: req.body?.description != null ? String(req.body.description) : null,
createdBy: 'user',
});
res.status(201).json(ev);
});
// 予定の更新(編集権限保有者のみ、他スペースの id は 404
router.patch('/:id/calendar/events/:eventId', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const eventId = Number(req.params.eventId);
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null } = {};
if (req.body?.date !== undefined) {
const d = String(req.body.date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
patch.date = d;
}
// 終了日。null/'' で単日に戻す。開始日(更新後の値)より前は 400。
const effectiveStart = patch.date ?? existing.date;
if (req.body?.end_date !== undefined) {
if (req.body.end_date === null || req.body.end_date === '') {
patch.endDate = null;
} else {
const ed = String(req.body.end_date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(ed)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
if (ed < effectiveStart) return res.status(400).json({ error: 'end_date must be on or after date' });
patch.endDate = ed > effectiveStart ? ed : null;
}
} else if (patch.date !== undefined && existing.endDate && existing.endDate < patch.date) {
// 開始日だけを既存終了日より後ろにずらした場合は整合のため単日へ。
patch.endDate = null;
}
if (req.body?.time !== undefined) {
const t = req.body.time === null || req.body.time === '' ? null : String(req.body.time);
if (t != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(t)) return res.status(400).json({ error: 'time must be HH:MM' });
patch.time = t;
}
// 終了時刻。null/'' で解除。形式のみここで検証し、順序・開始有無は実効値で下記一括チェック。
if (req.body?.end_time !== undefined) {
const et = req.body.end_time === null || req.body.end_time === '' ? null : String(req.body.end_time);
if (et != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(et)) return res.status(400).json({ error: 'end_time must be HH:MM' });
patch.endTime = et;
}
// 時刻の整合は「更新後の実効値」で検証する。time/date/end_date だけを変えて
// end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、
// 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。
{
const effTime = patch.time !== undefined ? patch.time : existing.time;
const effEndTime = patch.endTime !== undefined ? patch.endTime : existing.endTime;
if (effEndTime != null) {
if (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' });
const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate;
const multiDay = effEnd != null && effEnd > effectiveStart;
if (!multiDay && effEndTime < effTime) return res.status(400).json({ error: 'end_time must be on or after the start time' });
}
}
if (req.body?.title !== undefined) {
const t = String(req.body.title).trim();
if (!t) return res.status(400).json({ error: 'title is required' });
patch.title = t;
}
if (req.body?.description !== undefined) {
patch.description = req.body.description === null ? null : String(req.body.description);
}
const updated = await repo.updateCalendarEvent(eventId, patch);
res.json(updated);
});
// 予定の削除(編集権限保有者のみ、他スペースの id は 404
router.delete('/:id/calendar/events/:eventId', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const eventId = Number(req.params.eventId);
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
await repo.deleteCalendarEvent(eventId);
res.status(204).end();
});
}

View File

@ -0,0 +1,477 @@
import express, { Router } from 'express';
import { dirname, join, extname, basename, relative } from 'node:path';
import {
mkdirSync, readdirSync, statSync, readFileSync, openSync, writeSync,
closeSync, unlinkSync, rmSync, writeFileSync, renameSync, existsSync,
} from 'node:fs';
import AdmZip from 'adm-zip';
import { spaceFilesDir } from '../spaces/paths.js';
import { canEditInSpace } from './visibility.js';
import {
ensurePathWithin,
isPathEscapeError,
isNotFoundError,
serializeLocalFileEntry,
setUntrustedFileResponseHeaders,
isProtectedWorkspaceDir,
collectZipFiles,
} from './local-api-helpers.js';
import { logger } from '../logger.js';
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';
import { viewerOf } from './space-viewer.js';
import type { SpaceApiDeps } from './space-api.js';
/**
* Space /:id/files/* router
* createSpaceApispace-api.ts
* spaceFilesDir(worktreeDir, id) root ensurePathWithin
* task local-files-api.ts
*/
export function registerSpaceFilesRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo, worktreeDir } = deps;
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
// ─── ファイル窓(スペースの永続ワークスペース {worktreeDir}/space/{id}/files────────
//
// タスク版local-files-api.tsの section='space' に相当する経路を、スペース id で
// キーした形で提供する。閉じ込めは spaceFilesDir(worktreeDir, id) を root にした
// ensurePathWithinrealpath ではなく resolve ベースの正規化ガード。task の files API と同一)で行う。
// 可視性は getSpace({viewer}) が null を返したら 404owner/org/public の判定を repo に委譲)。
// 一覧
router.get('/:id/files', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const rootDir = spaceFilesDir(worktreeDir, space.id);
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true })
// 計画5 (Phase C): 成果物だけの綺麗なツリーにするため、実行ログ (logs)・
// 競合台帳 (.conflict)・全ドットファイルをファイル窓から除外する。
.filter((entry) => entry.name !== 'logs' && entry.name !== '.conflict' && !entry.name.startsWith('.'))
.map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: 'space', path: relativeDir, entries });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] files list not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] files list error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
// テキスト本文(プレビュー用)
router.get('/:id/files/content', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] file content not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] file content error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// バイナリ配信 / HTML アプリ実行trusted=1
router.get('/:id/files/raw', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
// trusted=1 で CSP sandbox を外し、生成 HTML をアプリ origin 上でアプリとして実行させる。
// 信頼モデルは「スペースのオーナーは信頼できる同僚」(社内 Gitea-org ツール)。
// 手前の getSpace({viewer}) で可視性private=owner+admin / org / publicを通過した
// 閲覧者には trusted 版を配る。受容するトレードオフ: オーナーの無サンドボックス HTML は
// 閲覧者のセッションで動くため、悪意あるオーナーは閲覧者になりすませる(オーナーは信頼前提)。
// ハードフロア: 未認証(実 req.user なし)には trusted を渡さない=共有リンクはまず
// ログインを経由する。viewerOf は authActive 時に synthetic admin を返しうるので、
// ここは viewer ではなく実認証ユーザー (req.user) の有無で判定する。
// 認証 OFF の単一運用者モードでは唯一の主体が全スペースの ownerself-XSS のみ)。
const trustedAllowed = deps.authActive ? !!(req as { user?: Express.User }).user : true;
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
if (!trustedHtml) setUntrustedFileResponseHeaders(res);
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] file raw not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] file raw error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
// Excel / PowerPoint プレビュー(変換して JSON で返す)。閲覧操作なので getSpace 可視性ゲートのみ。
// Excel→シートのセル配列、PPTX→スライド画像(PNG data URL)。soffice 未導入なら 503。
router.get('/:id/files/office-preview', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) return res.status(400).json({ error: 'path is required' });
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
await sendOfficePreview(res, filePath, basename(filePath));
} catch (err) {
handleOfficePreviewError(res, err);
}
});
// アップロードowner / admin / member.role∈{owner,editor})。共有スペースは協働モデルなので
// 行 owner 限定にはしないが、viewer ロールのメンバーは read のみで書き込み不可。
// base64-JSON 方式添付と同形、multipart dep 無し)。同名衝突は `{stem} (N){ext}` に
// O_EXCL で確保してリネーム上書き禁止。path は ensurePathWithin で spaceFilesDir に封じ込め。
router.post('/:id/files/upload', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const files = req.body?.files;
if (!Array.isArray(files) || files.length === 0) {
return res.status(400).json({ error: 'files must be a non-empty array' });
}
const reqPath = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const filesDir = spaceFilesDir(worktreeDir, space.id);
const uploaded: { name: string; path: string }[] = [];
for (const file of files) {
const safeName = String(file?.name ?? '').replace(/[\\/]/g, '_');
if (!safeName) return res.status(400).json({ error: 'file name is required' });
const rel = join(reqPath, safeName);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
mkdirSync(dirname(abs), { recursive: true });
const buf = Buffer.from(String(file?.contentBase64 ?? ''), 'base64');
// 衝突回避: 既存なら `{stem} (N){ext}` を O_EXCL で予約して書く(上書き禁止)。
const ext = extname(safeName);
const stem = basename(safeName, ext);
let candidateName = safeName;
let candidateAbs = abs;
let fd: number | undefined;
for (let n = 1; ; n++) {
if (n > 1) {
candidateName = `${stem} (${n})${ext}`;
candidateAbs = ensurePathWithin(filesDir, join(reqPath, candidateName));
}
try {
fd = openSync(candidateAbs, 'wx'); // O_EXCL: 既存なら EEXIST
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
throw e;
}
}
try {
writeSync(fd, buf);
} finally {
closeSync(fd);
}
const writtenRel = reqPath ? `${reqPath}/${candidateName}` : candidateName;
uploaded.push({ name: candidateName, path: writtenRel });
}
res.json({ uploaded });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file upload error: ${err}`);
res.status(500).json({ error: 'Failed to upload files' });
}
});
// 削除owner / admin / member.role∈{owner,editor}。viewer ロールは read のみで削除不可。
// body は { paths: string[] }(複数選択)または単一 { path: string }。各パスは
// ensurePathWithin で spaceFilesDir に封じ込めtraversal は throw → 400、ファイルのみ
// 対象ディレクトリはスキップskipped に記録、ファイル窓を壊さない)。存在しないパスは
// 黙ってスキップ(冪等)。削除結果は { deleted, skipped } で返す。
router.post('/:id/files/delete', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const deleted: string[] = [];
const skipped: string[] = [];
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) { skipped.push(String(raw ?? '')); continue; }
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
let stat;
try {
stat = statSync(abs);
} catch {
skipped.push(rel); // 存在しない → 冪等にスキップ
continue;
}
if (stat.isFile()) {
unlinkSync(abs);
deleted.push(rel);
} else if (stat.isDirectory()) {
// 構造フォルダinput/output/logs/apps/readonly/source/skills/subtasksは足場なので
// 削除禁止UI ガードだけだと API 直叩きで消せるためサーバ側でも拒否)。
if (isProtectedWorkspaceDir(filesDir, abs)) { skipped.push(rel); continue; }
rmSync(abs, { recursive: true, force: true }); // フォルダごと(中身含む)削除
deleted.push(rel);
} else {
skipped.push(rel); // 特殊ファイル等は対象外
}
}
res.json({ deleted, skipped });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file delete error: ${err}`);
res.status(500).json({ error: 'Failed to delete files' });
}
});
// --- フォルダ作成 / リネーム・移動Box ライクなファイル整理。owner/editor のみ) ---
// 構造ディレクトリと隠し(.git/.conflict 等)はリネーム・移動の対象外にして、
// 誤操作でワークスペースを壊さないようにする。source/ は出典ライブラリ、skills/ は
// スキルの作業コピーで、いずれもエージェントが自動生成・参照する構造フォルダ。
const PROTECTED_TOP_DIRS = new Set(['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills']);
function isProtectedTopLevel(rel: string): boolean {
if (!rel || rel.includes('/')) return false; // トップレベルのみ保護対象
return PROTECTED_TOP_DIRS.has(rel) || rel.startsWith('.');
}
/** dest が既存なら `{stem} (N){ext}` に退避した非衝突パスabs と relを返す。 */
function freeDestination(filesDir: string, destRel: string): { abs: string; rel: string } {
let rel = destRel;
let abs = ensurePathWithin(filesDir, rel);
if (!existsSync(abs)) return { abs, rel };
const dir = dirname(destRel);
const ext = extname(destRel);
const stem = basename(destRel, ext);
for (let n = 2; ; n++) {
const name = `${stem} (${n})${ext}`;
rel = (dir === '.' ? name : `${dir}/${name}`);
abs = ensurePathWithin(filesDir, rel);
if (!existsSync(abs)) return { abs, rel };
}
}
// フォルダ作成: 現在パス配下に空フォルダを作る。readonly/ を既存スペースに後付けする
// backfill 用途も兼ねる。reserved 名でも作成は許可(冪等・無害)。
router.post('/:id/files/mkdir', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rel = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) return res.status(400).json({ error: 'path is required' });
const filesDir = spaceFilesDir(worktreeDir, space.id);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
if (existsSync(abs) && !statSync(abs).isDirectory()) {
return res.status(409).json({ error: '同名のファイルが既にあります' });
}
mkdirSync(abs, { recursive: true });
res.json({ created: rel });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] mkdir error: ${err}`);
res.status(500).json({ error: 'Failed to create folder' });
}
});
// リネーム/移動: from を to へ。to は UI が算出した完全パス(リネーム=親同じ+新名、
// 移動=別フォルダ+同名)。構造ディレクトリ/隠しの from は不可。衝突は自動リネーム。
router.post('/:id/files/move', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const from = String(req.body?.from ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const to = String(req.body?.to ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
const filesDir = spaceFilesDir(worktreeDir, space.id);
// 先に正規化resolve + traversal ガード)してから保護判定する。
// 生文字列で isProtectedTopLevel を判定すると `./input` などの dot 形で
// 構造ディレクトリ保護を迂回できてしまうresolve 後の実体は input/ なのに、
// 生文字列は "/" を含むため top-level 扱いされず素通りする)ため。
const fromAbs = ensurePathWithin(filesDir, from); // escape は throw → 下で 400 化
const toAbsRaw = ensurePathWithin(filesDir, to);
const fromRelNorm = relative(filesDir, fromAbs).split(/[\\/]/).join('/');
const toRelNorm = relative(filesDir, toAbsRaw).split(/[\\/]/).join('/');
// from が構造ディレクトリ「そのもの」のときだけ拒否する。isProtectedTopLevel は
// `/` を含むパスを false にするので、正規化済みの完全相対パスで判定すれば
// 構造フォルダ自体(`./input` 等の dot 形含む)は弾きつつ、配下のファイル
// output/x.txt 等の移動・リネームは許可できるto 側の判定とも対称)。
if (isProtectedTopLevel(fromRelNorm)) {
return res.status(400).json({ error: 'このフォルダは移動・リネームできません' });
}
// to が root 直下の予約名input/output/logs/apps/readonly・隠しそのものになるのも防ぐ
// (偽の構造ディレクトリを作る/置き換えるのを防止。配下への移動to=output/xは許可。
if (!toRelNorm.includes('/') && isProtectedTopLevel(toRelNorm)) {
return res.status(400).json({ error: 'その名前は使えません(予約フォルダです)' });
}
if (!existsSync(fromAbs)) return res.status(404).json({ error: 'source not found' });
// ディレクトリを自分自身(または配下)へ移動するのを防ぐ。
const within = relative(fromAbs, toAbsRaw);
if (within === '' || (!within.startsWith('..') && !within.startsWith('/'))) {
return res.status(400).json({ error: 'フォルダを自分自身の中へは移動できません' });
}
mkdirSync(dirname(toAbsRaw), { recursive: true });
// freeDestination は正規化済み相対パスで呼ぶ(`./` 等が結果 rel に残らないように)。
const { abs: toAbs, rel: toRel } = freeDestination(filesDir, toRelNorm);
renameSync(fromAbs, toAbs);
res.json({ from, to: toRel });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] move error: ${err}`);
res.status(500).json({ error: 'Failed to move' });
}
});
// 複数ファイルを zip でダウンロード。ダウンロードは閲覧操作なので read ゲート
// getSpace が viewer で読めれば可。canEditInSpace は不要)。各 path は spaceFilesDir に
// 封じ込めtraversal は 400。ファイルのみ、ディレクトリ/不在はスキップ。zip エントリ名は
// 相対パス(絶対パスを漏らさない)。
router.post('/:id/files/download-zip', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const zip = new AdmZip();
let added = 0;
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) continue;
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
// ファイルはそのまま、ディレクトリは配下を再帰収集entry 名は filesDir 相対で
// フォルダ構造を保持。zip-slip 対策・symlink 除外は collectZipFiles 内)。
for (const { abs: fileAbs, entryName } of collectZipFiles(filesDir, abs)) {
zip.addFile(entryName, readFileSync(fileAbs));
added++;
}
}
if (added === 0) return res.status(404).json({ error: 'no downloadable files' });
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="files.zip"');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.send(zip.toBuffer());
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file zip error: ${err}`);
res.status(500).json({ error: 'Failed to build zip' });
}
});
// プログラム書込1 ファイルを path 指定で上書き。upload が O_EXCL で衝突回避リネーム
// するのに対し、こちらは「同じパスを更新する」用途(ワークスペース・アプリの postMessage
// ブリッジ writeFile 等)のため**上書き許可**。本文は base64任意バイナリまたは content
// UTF-8 テキスト)で受ける。書込ゲートは upload/delete と同じ canEditInSpaceowner /
// admin / member.role∈{owner,editor}。path は ensurePathWithin で spaceFilesDir に封じ込め
// traversal は throw → 400。親ディレクトリは mkdir -p。
router.post('/:id/files/write', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rel = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!rel) return res.status(400).json({ error: 'path is required' });
const hasBase64 = typeof req.body?.contentBase64 === 'string';
const hasText = typeof req.body?.content === 'string';
if (!hasBase64 && !hasText) {
return res.status(400).json({ error: 'content or contentBase64 is required' });
}
const buf = hasBase64
? Buffer.from(String(req.body.contentBase64), 'base64')
: Buffer.from(String(req.body.content), 'utf-8');
// サイズ上限DoS / ディスク枯渇対策)。
const MAX_WRITE_BYTES = 10 * 1024 * 1024;
if (buf.length > MAX_WRITE_BYTES) {
return res.status(400).json({ error: `content exceeds ${MAX_WRITE_BYTES} bytes` });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
// 書込先は apps/ と output/ サブツリーに限定する。任意 .html や AGENTS.md /
// source/index.jsonl 等を上書きされ、trusted-html 無サンドボックス配信経路と
// 組み合わさって stored XSS になるのを防ぐ(ブリッジ writeFile の正当な用途は
// アプリの出力 = output/ と アプリ自身のデータ = apps/。escape は ensurePathWithin
// が既に弾く。正規化後の相対先頭セグメントで判定する。
const normRel = relative(filesDir, abs).split(/[\\/]/);
if (normRel[0] !== 'apps' && normRel[0] !== 'output') {
return res.status(403).json({ error: 'write target must be under apps/ or output/' });
}
// 既存ディレクトリを潰さないガード(ファイルのみ上書き対象)。
try {
if (statSync(abs).isDirectory()) {
return res.status(400).json({ error: 'path points to a directory' });
}
} catch { /* 非存在は新規作成 */ }
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, buf);
res.json({ path: rel, bytes: buf.length });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file write error: ${err}`);
res.status(500).json({ error: 'Failed to write file' });
}
});
}

View File

@ -0,0 +1,116 @@
import express, { Router } from 'express';
import { canManageSpace } from './visibility.js';
import { viewerOf } from './space-viewer.js';
import type { SpaceInvite, SpaceInviteRole } from '../db/repository.js';
import type { SpaceApiDeps } from './space-api.js';
/**
* createSpaceApi
*
* / canManageSpace
* no-auth invite 404
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
*/
export function registerSpaceInviteRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo } = deps;
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
const INVITE_ROLES: readonly SpaceInviteRole[] = ['editor', 'viewer'];
const isInviteRole = (r: unknown): r is SpaceInviteRole =>
typeof r === 'string' && (INVITE_ROLES as readonly string[]).includes(r);
// invite を API レスポンス形に整えるurl は相対パス。SPA が origin を前置する)。
const inviteOut = (inv: SpaceInvite) => ({
token: inv.token,
url: `/ui/invite/${inv.token}`,
role: inv.role,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
revokedAt: inv.revokedAt,
valid: repo.isSpaceInviteValid(inv),
});
// 現行リンクを取得canManageSpace。無ければ { invite: null }。
router.get('/:id/invite', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const inv = repo.getSpaceInvite(space.id);
res.json({ invite: inv ? inviteOut(inv) : null });
});
// リンクを (再)生成canManageSpace。body { role, expiresInDays? }。
router.post('/:id/invite', jsonParser, async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const role = req.body?.role ?? 'viewer';
if (!isInviteRole(role)) return res.status(400).json({ error: 'invalid role' });
const rawDays = req.body?.expiresInDays;
let expiresInDays: number | null = null;
if (rawDays != null) {
const n = Number(rawDays);
if (!Number.isInteger(n) || n <= 0) {
return res.status(400).json({ error: 'expiresInDays must be a positive integer' });
}
expiresInDays = n;
}
const inv = repo.createSpaceInvite({ spaceId: space.id, role, createdBy: viewer.id, expiresInDays });
res.status(201).json({ invite: inviteOut(inv) });
});
// リンクを無効化canManageSpace
router.delete('/:id/invite', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
repo.revokeSpaceInvite(space.id);
res.status(204).end();
});
// プレビュー(認証済み・メンバーでなくてよい)。無効/不明トークンは 404 で、
// スペース情報を一切返さない(列挙・情報漏洩対策)。
// ルーティング: '/invite/:token' は1セグメント目が literal 'invite'。'/:id/invite'
// とは排他(スペース id は UUID で 'invite' と衝突しない)。
router.get('/invite/:token', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const inv = repo.getSpaceInviteByToken(req.params.token);
if (!repo.isSpaceInviteValid(inv)) return res.status(404).json({ error: 'invalid invite' });
const space = await repo.getSpace(inv.spaceId, { viewer: { id: 'x', role: 'admin', orgIds: [] } as unknown as Express.User });
if (!space) return res.status(404).json({ error: 'invalid invite' });
res.json({ spaceId: space.id, spaceTitle: space.title, role: inv.role });
});
// 参加(認証済み)。検証 → メンバー追加(冪等)。既メンバー/オーナーも 200。
router.post('/invite/:token/accept', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const inv = repo.getSpaceInviteByToken(req.params.token);
if (!repo.isSpaceInviteValid(inv)) return res.status(404).json({ error: 'invalid invite' });
const space = await repo.getSpace(inv.spaceId, { viewer: { id: 'x', role: 'admin', orgIds: [] } as unknown as Express.User });
if (!space) return res.status(404).json({ error: 'invalid invite' });
// オーナー本人 → 既に最上位権限。メンバー行は作らず成功扱い。
if (space.ownerId && viewer.id === space.ownerId) {
return res.json({ spaceId: space.id, alreadyMember: true });
}
const existing = repo.getSpaceMemberRole(space.id, viewer.id);
if (existing) return res.json({ spaceId: space.id, alreadyMember: true });
await repo.addSpaceMember({ spaceId: space.id, userId: viewer.id, role: inv.role, invitedBy: inv.createdBy });
res.json({ spaceId: space.id, alreadyMember: false });
});
}

View File

@ -0,0 +1,134 @@
import express, { Router } from 'express';
import { canManageSpace } from './visibility.js';
import { viewerOf } from './space-viewer.js';
import type { SpaceMemberRoleValue } from '../db/repository.js';
import type { SpaceApiDeps } from './space-api.js';
/** スペースのメンバー協働者CRUD。createSpaceApi から分離(巨大関数分割)。挙動は等価。 */
export function registerSpaceMembersRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo } = deps;
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
// ─── メンバー(スペースの協働者)─────────────────────────────────────
//
// 可視性は他のスペース配下ルートと同じく getSpace({viewer}) が null を返したら 404。
// 一覧はスペース可視者なら誰でも可read。追加/変更/削除は canManageSpace
// owner / admin / member.role==='owner')。自分自身を抜けるのは管理権限が無くても可。
// owner_id 本人は常に owner として合成して返し、member 行を持つことはできない。
const VALID_MEMBER_ROLES: readonly SpaceMemberRoleValue[] = ['owner', 'editor', 'viewer'];
const isValidRole = (r: unknown): r is SpaceMemberRoleValue =>
typeof r === 'string' && (VALID_MEMBER_ROLES as readonly string[]).includes(r);
// 一覧スペース可視者なら誰でも。owner_id を owner として合成し、member 行を後続。
router.get('/:id/members', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const members = await repo.listSpaceMembers(space.id);
// email は PII。public/org スペースでは getSpace を通る非メンバー閲覧者にも
// メンバー一覧が見えるため、email は「資格者」(admin / owner / メンバー本人) に
// だけ返す。それ以外には name + avatar のみ(メールアドレス収集ベクター対策)。
const viewerEntitled =
viewer.role === 'admin' ||
space.ownerId === viewer.id ||
(await repo.getSpaceMemberRole(space.id, viewer.id)) !== null;
const maskEmail = (email: string | null) => (viewerEntitled ? email : null);
const out: Array<{
userId: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
role: SpaceMemberRoleValue;
isOwner: boolean;
}> = [];
// owner_id を合成 owner として先頭に置く(行が重複しても owner 行が勝つ)。
if (space.ownerId) {
const ownerUser = repo.getUserById(space.ownerId);
out.push({
userId: space.ownerId,
name: ownerUser?.name ?? null,
email: maskEmail(ownerUser?.email ?? null),
avatarUrl: ownerUser?.avatarUrl ?? null,
role: 'owner',
isOwner: true,
});
}
for (const m of members) {
if (space.ownerId && m.userId === space.ownerId) continue; // owner 行が勝つ → de-dupe
out.push({
userId: m.userId,
name: m.name,
email: maskEmail(m.email),
avatarUrl: m.avatarUrl,
role: m.role,
isOwner: false,
});
}
res.json(out);
});
// 追加 / 招待canManageSpace。owner_id 本人は追加不可。未知ユーザー・不正ロールは 400。
router.post('/:id/members', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const userId = (req.body?.userId ?? '').toString();
const role = req.body?.role;
if (!userId) return res.status(400).json({ error: 'userId is required' });
if (space.ownerId && userId === space.ownerId) {
return res.status(400).json({ error: 'user is already the space owner' });
}
if (!isValidRole(role)) return res.status(400).json({ error: 'invalid role' });
if (!repo.getUserById(userId)) return res.status(400).json({ error: 'unknown user' });
await repo.addSpaceMember({ spaceId: space.id, userId, role, invitedBy: viewer.id });
res.status(201).json({ userId, role });
});
// ロール変更canManageSpace。メンバーでなければ 404、不正ロールは 400。
router.patch('/:id/members/:userId', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const targetUserId = req.params.userId;
const role = req.body?.role;
if (!isValidRole(role)) return res.status(400).json({ error: 'invalid role' });
if (repo.getSpaceMemberRole(space.id, targetUserId) === null) {
return res.status(404).json({ error: 'member not found' });
}
await repo.updateSpaceMemberRole(space.id, targetUserId, role);
res.json({ userId: targetUserId, role });
});
// 除去canManageSpace、または自分自身を抜ける場合。メンバーでなければ 404。
router.delete('/:id/members/:userId', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const targetUserId = req.params.userId;
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
const isSelfRemoval = targetUserId === viewer.id;
if (!isSelfRemoval && !canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
if (repo.getSpaceMemberRole(space.id, targetUserId) === null) {
return res.status(404).json({ error: 'member not found' });
}
await repo.removeSpaceMember(space.id, targetUserId);
res.status(204).end();
});
}

View File

@ -0,0 +1,131 @@
import express, { Router } from 'express';
import { canManageSpace } from './visibility.js';
import { viewerOf } from './space-viewer.js';
import { logger } from '../logger.js';
import { parseToolPolicy } from '../engine/workspace-tool-policy.js';
import {
listToolCategories,
SENSITIVE_CATEGORIES,
SENSITIVE_TOOLS,
} from '../engine/tools/tool-categories.js';
import type { SpaceApiDeps } from './space-api.js';
/** スペースのツールポリシー GET/PUT。createSpaceApi から分離(巨大関数分割)。挙動は等価。 */
export function registerSpaceToolPolicyRoutes(router: Router, deps: SpaceApiDeps): void {
const { repo } = deps;
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
// ─── ツールポリシー ─────────────────────────────────────────────────────────
//
// GET: スペース可視者viewer 以上)が読める。
// PUT: canManageSpaceowner / admin / member.role==='owner')のみ書ける。
//
// 返却形状GET / PUT 共通):
// { policy: WorkspaceToolPolicy,
// categories: { name, sensitive, enabled }[],
// sensitiveTools: { name, enabled }[] }
//
// 未知のカテゴリ/ツール名を enabledSensitive / disabledSafe に含めると 400。
// 既知かどうかの判定: SENSITIVE_CATEGORIES listToolCategories() の和集合 +
// SENSITIVE_TOOLS を allowlist として使う。
async function buildPolicyResponse(spaceId: string) {
const json = repo.getSpaceToolPolicy(spaceId);
const policy = parseToolPolicy(json);
const allCats = await listToolCategories();
const disabledSafe = new Set(policy.disabledSafe ?? []);
const enabledSensitive = new Set(policy.enabledSensitive ?? []);
const categories = allCats.map((name) => {
const sensitive = SENSITIVE_CATEGORIES.has(name);
const enabled = sensitive ? enabledSensitive.has(name) : !disabledSafe.has(name);
return { name, sensitive, enabled };
});
const sensitiveTools = [...SENSITIVE_TOOLS].map((name) => ({
name,
enabled: enabledSensitive.has(name),
}));
return { policy, categories, sensitiveTools };
}
// 既知のカテゴリ+ツール名の allowlist を組み立てるvalidation 用)
async function buildKnownSensitiveNames(): Promise<Set<string>> {
const allCats = await listToolCategories();
const known = new Set<string>(SENSITIVE_CATEGORIES);
for (const c of allCats) known.add(c); // all cats are valid candidates
for (const t of SENSITIVE_TOOLS) known.add(t);
return known;
}
// GET /:id/tool-policy — viewer 可
router.get('/:id/tool-policy', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
try {
const body = await buildPolicyResponse(space.id);
res.json(body);
} catch (err) {
logger.error(`[space-api] tool-policy GET failed space=${space.id} err=${(err as Error).message}`);
res.status(500).json({ error: 'internal error' });
}
});
// PUT /:id/tool-policy — canManageSpace 必須
router.put('/:id/tool-policy', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const { disabledSafe, enabledSensitive } = req.body ?? {};
// Validate: both fields must be arrays of strings if present
if (disabledSafe !== undefined && !Array.isArray(disabledSafe)) {
return res.status(400).json({ error: 'disabledSafe must be an array' });
}
if (enabledSensitive !== undefined && !Array.isArray(enabledSensitive)) {
return res.status(400).json({ error: 'enabledSensitive must be an array' });
}
if (disabledSafe && disabledSafe.some((x: unknown) => typeof x !== 'string')) {
return res.status(400).json({ error: 'disabledSafe must be an array of strings' });
}
if (enabledSensitive && enabledSensitive.some((x: unknown) => typeof x !== 'string')) {
return res.status(400).json({ error: 'enabledSensitive must be an array of strings' });
}
// Validate: unknown category/tool names → 400
const known = await buildKnownSensitiveNames();
if (disabledSafe) {
const unknown = (disabledSafe as string[]).filter((n) => !known.has(n));
if (unknown.length > 0) {
return res.status(400).json({ error: `unknown category names in disabledSafe: ${unknown.join(', ')}` });
}
}
if (enabledSensitive) {
const unknown = (enabledSensitive as string[]).filter((n) => !known.has(n));
if (unknown.length > 0) {
return res.status(400).json({ error: `unknown names in enabledSensitive: ${unknown.join(', ')}` });
}
}
const cleanPolicy = {
...(disabledSafe && disabledSafe.length > 0 ? { disabledSafe: disabledSafe as string[] } : {}),
...(enabledSensitive && enabledSensitive.length > 0 ? { enabledSensitive: enabledSensitive as string[] } : {}),
};
try {
repo.setSpaceToolPolicy(space.id, JSON.stringify(cleanPolicy));
const body = await buildPolicyResponse(space.id);
res.json(body);
} catch (err) {
logger.error(`[space-api] tool-policy PUT failed space=${space.id} err=${(err as Error).message}`);
res.status(500).json({ error: 'internal error' });
}
});
}

View File

@ -0,0 +1,20 @@
/**
* space-viewer.ts viewer
* createSpaceApispace-api.ts space-files-api.ts 使
* import spaceViewerOf space-api.ts
* cross-calendar-api import
*/
/**
* OFF synthetic 'local' no-auth local
* role 'admin' server.ts no-auth
* deserializeUser `{ id: 'local', role: 'admin' }`
* memory-api / reflection-api role:'user' owner_id
* role owner_id=null local
* canEditEntity role:'admin' owner
* 'user' owner_id=null
*/
export function viewerOf(req: any, authActive: boolean): Express.User {
if (authActive && req.user) return req.user;
return req.user ?? ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User);
}

369
src/bridge/ssh-subsystem.ts Normal file
View File

@ -0,0 +1,369 @@
import express, { Request, Response, NextFunction } from 'express';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { loadConfig } from '../config.js';
import { requireAuth, requireAdmin, resolveOrgIds } from './auth.js';
import { mergeSshConfig } from '../ssh/config.js';
import { isKeyConfigured } from '../mcp/crypto.js';
import {
bootstrapSystemDek,
verifySystemDek,
encryptPrivateKey as sshEncryptPrivateKey,
decryptPrivateKey as sshDecryptPrivateKey,
computeKeyFingerprint as sshComputeKeyFingerprint,
formatPublicKey as sshFormatPublicKey,
generateKeypair as sshGenerateKeypair,
type GeneratedKeyType as SshGeneratedKeyType,
} from '../ssh/crypto.js';
import { createConnectionRepo } from '../ssh/connection-repo.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import { createAuditRepo } from '../ssh/audit-repo.js';
import { createAbuseRepo } from '../ssh/abuse-repo.js';
import { createAccessResolver } from '../ssh/access.js';
import { maintenance as sshMaintenance } from '../ssh/maintenance.js';
import { createAdminRateLimiter, FORCE_UNLOCK_LIMIT } from '../ssh/admin-rate-limit.js';
import {
sshTest,
sshExec,
sshUpload,
sshDownload,
openShellChannel,
type ResolvedConnection as SshResolvedConnection,
} from '../ssh/session.js';
import { SessionRegistry } from '../ssh/console-registry.js';
import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js';
import { setSshSubsystem, preflight as sshPreflight, type SshSubsystem } from '../engine/tools/ssh.js';
import { __setActiveSessionLookup } from '../engine/agent-loop.js';
import {
createConsoleStatusRouter,
createConsoleSessionRouter,
type SimpleTask,
type SimpleUser,
} from './console-ws-api.js';
import { createConsoleAdminRouter } from './console-admin-api.js';
/**
* Deps the SSH Console WebSocket upgrade hook + status endpoints need.
* Captured by setupSshSubsystem and consumed by startCoreServer to wire the
* WS upgrade onto the http.Server it eventually creates. Null when SSH is
* disabled / failed init.
*/
export interface SshConsoleDeps {
registry: SessionRegistry;
resolveUserFromUpgrade: (req: import('http').IncomingMessage) => Promise<SimpleUser | null>;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
resolveSshAccess: (
user: SimpleUser,
session: import('../ssh/console-session.js').ConsoleSession,
task: SimpleTask,
) => Promise<boolean>;
denyPatterns: import('./console-ws-api.js').DenyPatternProvider;
}
export interface SshSubsystemDeps {
repo: Repository;
authActive: boolean;
authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
}
/**
* Wire the SSH subsystem (connection / grants / audit / abuse repos, access
* resolver, session registry, crypto wrappers) and mount its admin/user REST
* routers + Console status/session/admin routers. Gated on ssh.enabled AND
* MCP_ENCRYPTION_KEY.
*
* Returns the SshConsoleDeps for startCoreServer to attach the Console WS
* upgrade hook, or null when SSH is disabled / fails to initialise.
*
* Extracted verbatim from createCoreServer (server.ts); see
* server-subsystems.test.ts for the gate characterization.
*/
export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDeps): SshConsoleDeps | null {
const { repo, authActive, authenticateUpgrade } = deps;
// Phase 5 (SSH Console): sshConsole is captured here so that
// startCoreServer() can wire the WS upgrade hook to the http.Server
// it eventually creates. null when SSH is disabled / failed init.
let sshConsole: SshConsoleDeps | null = null;
const sshConfig = mergeSshConfig(loadConfig().ssh);
if (!sshConfig.enabled) {
setSshSubsystem(null);
__setActiveSessionLookup(null);
} else if (!isKeyConfigured()) {
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
setSshSubsystem(null);
__setActiveSessionLookup(null);
} else {
try {
bootstrapSystemDek(repo.getDb());
verifySystemDek(repo.getDb());
const connectionRepo = createConnectionRepo(repo.getDb());
const grantsRepo = createGrantsRepo(repo.getDb());
const auditRepo = createAuditRepo(repo.getDb());
const abuseRepo = createAbuseRepo(repo.getDb(), {
windowMinutes: sshConfig.abuseWindowMinutes,
failureThreshold: sshConfig.abuseFailureThreshold,
lockMinutes: sshConfig.abuseLockMinutes,
});
const accessResolver = createAccessResolver(grantsRepo, {
adminBypassesGrants: sshConfig.adminBypassesGrants,
});
const forceUnlockLimiter = createAdminRateLimiter(FORCE_UNLOCK_LIMIT);
// Hoisted above sshDeps so the grant-revocation hook can call
// sessionRegistry.revokeAccessFor (introduced for Phase 5 hardening:
// kick active WS viewers when their grant is deleted).
const sessionRegistry = new SessionRegistry({
idleTimeoutMs: sshConfig.console.idleTimeoutSeconds * 1000,
maxSessionDurationMs: sshConfig.console.maxSessionDurationSeconds * 1000,
maxSessionsPerConnection: sshConfig.console.maxSessionsPerConnection,
});
const sshDeps: SshApiDeps = {
db: repo.getDb(),
authActive,
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin',
getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
connectionRepo,
grantsRepo,
auditRepo,
abuseRepo,
accessResolver,
maintenance: sshMaintenance,
forceUnlockLimiter,
encryptKeyMaterial: (ownerId, pem, passphrase, spaceId) => {
const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem, spaceId);
const passphraseBlob = passphrase
? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase, spaceId).blob
: null;
const fingerprint = sshComputeKeyFingerprint(pem, passphrase);
const publicKey = sshFormatPublicKey(pem, passphrase);
return { blob, passphraseBlob, keyVersion, fingerprint, publicKey };
},
decryptKeyMaterial: (ownerId, blob, spaceId) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
decryptPassphrase: (ownerId, blob, spaceId) =>
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType),
derivePublicKey: (ownerId, blob, passphraseBlob, spaceId) => {
const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId);
const pass = passphraseBlob
? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob, spaceId)
: null;
try {
return sshFormatPublicKey(pem, pass);
} finally {
pem.fill(0);
if (pass) pass.fill(0);
}
},
sshTester: {
async test({ connection, decryptedKey, passphrase, timeoutMs }) {
const conn: SshResolvedConnection = {
id: connection.id,
ownerId: connection.ownerId,
host: connection.host,
port: connection.port,
username: connection.username,
privateKeyPem: decryptedKey,
passphrase: passphrase ?? undefined,
hostKeyB64: connection.hostKeyB64,
hostKeyVerified: connection.hostKeyVerifiedAt !== null,
allowPrivate:
sshConfig.allowPrivateAddresses || connection.allowPrivateAddresses,
};
return sshTest({ connection: conn, timeoutMs });
},
},
connectionTestTimeoutMs: sshConfig.callTimeoutSeconds * 1000,
onAccessRevoked: ({ connectionId, userId }) =>
sessionRegistry.revokeAccessFor({
connectionId,
userId,
reason: 'access_revoked',
}),
};
app.use('/api/ssh/admin', express.json(), createSshAdminRouter(sshDeps));
app.use('/api/ssh', express.json(), createSshUserRouter(sshDeps));
// Phase 7: register the SSH tool subsystem so SshExec / SshUpload /
// SshDownload tools can access the same repos / session primitives /
// crypto wrappers that the HTTP layer uses. sessionRegistry is
// constructed above (hoisted so sshDeps.onAccessRevoked can use it).
// Captured in a local const (not just passed to setSshSubsystem)
// so the user-initiated console-session REST endpoint can call the
// shared openConsoleSession core with the EXACT same `sub` the
// agent-facing console tools use — no second SshSubsystem.
const sshSubsystem: SshSubsystem = {
connectionRepo,
auditRepo,
abuseRepo,
accessResolver,
decryptKeyMaterial: (ownerId, blob, spaceId) =>
sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
decryptPassphrase: (ownerId, blob, spaceId) =>
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
getUserAccess: (userId) => {
const user = repo.getUserById(userId);
const isAdmin = user?.role === 'admin';
const orgIds = resolveOrgIds(repo, userId);
return { isAdmin, orgIds };
},
sshExec,
sshUpload,
sshDownload,
maintenance: sshMaintenance,
config: sshConfig,
sessionRegistry,
openShellChannel,
};
setSshSubsystem(sshSubsystem);
// Phase 4 (SSH Console): wire the registry into agent-loop so
// buildSystemPrompt can auto-inject the live screen tail into
// the LLM system prompt for movements that allow SshConsole*.
__setActiveSessionLookup((taskId) => sessionRegistry.get(taskId));
// Phase 5 (SSH Console): start the periodic sweep so idle /
// duration-cap sessions actually get closed. Without this, the
// registry just holds sessions until shutdown.
sessionRegistry.startSweepTimer(60_000);
// Phase 5 (SSH Console): when SSH maintenance mode activates
// (master-key rotation), close all live console sessions. They
// would otherwise hold a decrypted DEK reference past the
// rewrap window. The reason 'maintenance' is surfaced to the
// WS client as the close cause.
sshMaintenance.onEnter(async () => {
const all = sessionRegistry.listAll();
for (const s of all) {
await sessionRegistry.closeForTask(s.localTaskId, 'maintenance');
}
});
// Capture WS / status deps for startCoreServer to wire up.
const consoleDeps: SshConsoleDeps = {
registry: sessionRegistry,
resolveUserFromUpgrade: async (req) => {
if (!authenticateUpgrade) {
// No-auth single-user mode: synthesize a stable `local` admin
// user so the Console terminal WS attaches (admin role makes
// the null-owner no-auth task visible in resolveTask). Mirrors
// the Console REST routers and notifications-api.
return { id: 'local', role: 'admin' };
}
const u = await authenticateUpgrade(req);
return u ? { id: u.id, role: u.role } : null;
},
resolveTask: async (taskId, user) => {
const idNum = Number(taskId);
if (!Number.isFinite(idNum)) return null;
const viewer: Express.User = {
id: user.id,
email: '',
name: null,
avatarUrl: null,
role: (user.role === 'admin' ? 'admin' : 'user'),
status: 'active',
orgIds: resolveOrgIds(repo, user.id),
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.getLocalTask(idNum, { viewer });
if (!task) return null;
return {
id: String(task.id),
ownerId: task.ownerId ?? '',
visibility: task.visibility,
pieceName: task.pieceName,
};
},
resolveSshAccess: async (user, session, task) => {
const connection = connectionRepo.resolveConnection(session.connectionId);
if (!connection) return false;
const orgIds = resolveOrgIds(repo, user.id);
const decision = accessResolver.resolveAccess({
connection,
userId: user.id,
isAdmin: user.role === 'admin',
// Use the task's actual piece name so piece-specific grants in
// ssh_connection_grants match (applies_to_all_pieces=0 case).
// Bug pre-fix: hardcoded '' silently failed every piece-scoped grant.
pieceName: task.pieceName,
orgIds,
});
return decision.allowed;
},
denyPatterns: {
async getPatterns(connectionId: string) {
const c = connectionRepo.resolveConnection(connectionId);
if (!c) return { deny: [], allow: [] };
const split = (s: string | null): string[] =>
s ? s.split('\n').map((x) => x.trim()).filter((x) => x.length > 0) : [];
return {
deny: split(c.commandDenyPatterns),
allow: split(c.commandAllowPatterns),
};
},
},
};
sshConsole = consoleDeps;
// REST status endpoint: /api/local/tasks/:taskId/console/status
app.use(
'/api',
createConsoleStatusRouter({
registry: sessionRegistry,
authActive,
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
resolveTask: consoleDeps.resolveTask,
}),
);
// REST user-initiated session-open endpoint:
// POST /api/local/tasks/:taskId/console/session. Reuses the same
// SshSubsystem + preflight the console tools use; the access gate
// runs inside openConsoleSession against task.pieceName.
// NOTE: no express.json() here — the router is mounted on the whole
// /api prefix, so a mount-level parser (default limit 100kb) would
// run for EVERY /api request and 413 large bodies before the
// route-specific parsers (e.g. the task-attachment limit) ever ran.
// The session route carries its own scoped json() parser.
app.use(
'/api',
createConsoleSessionRouter({
sub: sshSubsystem,
preflight: sshPreflight,
authActive,
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
resolveTask: consoleDeps.resolveTask,
}),
);
// Phase 6 (SSH Console): admin list + kill endpoints. The
// `/api/admin` prefix already has `express.json()` mounted above
// (see Admin user management API), so POST bodies parse correctly.
app.use(
'/api/admin',
createConsoleAdminRouter({
registry: sessionRegistry,
requireAdmin: authActive ? requireAdmin : (_req: Request, _res: Response, next: NextFunction) => next(),
}),
);
logger.info('[ssh] subsystem initialised');
} catch (e) {
logger.error(`[ssh] init failed err=${String(e)}`);
setSshSubsystem(null);
__setActiveSessionLookup(null);
}
}
return sshConsole;
}

View File

@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { type Repository, type Job, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
import { canViewTask, resolveSpaceAccess, isJobWithinWorkspace } from './local-api-helpers.js';
const MAX_ACTIVITY_LOG_CHARS = 4000;
@ -79,7 +79,7 @@ export function createSubtaskActivityRouter(repo: Repository): Router {
if (!job || !job.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; }
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !job.worktreePath.startsWith(task!.workspacePath)) {
if (task!.workspacePath && !isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}

View File

@ -4,7 +4,7 @@ import { resolve, sep } from 'path';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
import { canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders, isJobWithinWorkspace } from './local-api-helpers.js';
export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
@ -29,7 +29,7 @@ export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
if (task!.workspacePath && !isJobWithinWorkspace(task!.workspacePath, subJob.worktreePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
@ -73,7 +73,7 @@ export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
if (task!.workspacePath && !isJobWithinWorkspace(task!.workspacePath, subJob.worktreePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}

View File

@ -226,4 +226,21 @@ describe('GET /api/tools (runtime catalog)', () => {
expect(placeholder!.reason).toMatch(/offline|no cached tools/);
expect(placeholder!.scope).toBe('user');
});
// Regression: a tool registered for runtime dispatch in tools/index.ts must
// ALSO be listed in this file's MODULE_SPECS, or it never appears in the
// catalog and cannot be added to a piece's allowed_tools from the UI.
// TestWorkspaceApp was dispatched but missing from MODULE_SPECS (the classic
// "tools-api.ts 登録漏れ" inconsistency).
it('includes TestWorkspaceApp so it is selectable in the piece editor', async () => {
const res = await request(makeApp()).get('/api/tools');
const tool = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.name === 'TestWorkspaceApp',
);
expect(tool).toBeDefined();
expect(tool!.source).toBe('builtin');
expect(tool!.category).toBe('app-test');
expect(tool!.scope).toBe('piece');
expect(tool!.available).toBe(true);
});
});

View File

@ -1,6 +1,6 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { ToolDef } from '../llm/openai-compat.js';
import { getSshSubsystem } from '../engine/tools/ssh.js';
import { META_TOOLS, MODULE_SPECS, type ToolModule } from '../engine/tools/tool-categories.js';
/**
* Tool catalog entry exposed by GET /api/tools.
@ -40,71 +40,7 @@ export interface ToolCatalogResponse {
tools: ToolCatalogEntry[];
}
/**
* Meta tools are auto-injected by `agent-loop.buildSystemPrompt()` regardless of
* a piece's `allowed_tools`. Keep this list in sync with `META_TOOLS` in
* `src/engine/tools/index.ts`.
*/
const META_TOOLS = new Set<string>([
'ReadToolDoc',
'CreateChecklist',
'CheckItem',
'GetChecklist',
'MissionUpdate',
'ListUserAssets',
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'ReadUserAgents',
'UpdateUserAgents',
'WriteUserScript',
'Brainstorm',
'ReadAppDoc',
'ListAppDocs',
'GetMyOrchestratorState',
'ReadSkill',
'ListSkills',
'InstallSkill',
]);
/**
* Modules to load. The key becomes the `category` field for builtin tools.
* `core` is loaded separately because its export name is `ALL_TOOL_DEFS`.
*
* Categories that map to "user-scoped" assets (per-user MCP servers, SSH) get
* scope='user' below see categoryScope().
*/
const MODULE_SPECS: Array<{ category: string; specifier: string }> = [
{ category: 'web', specifier: '../engine/tools/web.js' },
{ category: 'image', specifier: '../engine/tools/image.js' },
{ category: 'data', specifier: '../engine/tools/data.js' },
{ category: 'office', specifier: '../engine/tools/office.js' },
{ category: 'review', specifier: '../engine/tools/review.js' },
{ category: 'x', specifier: '../engine/tools/x.js' },
{ category: 'orchestration', specifier: '../engine/tools/orchestration.js' },
{ category: 'browser', specifier: '../engine/tools/browser.js' },
{ category: 'maps', specifier: '../engine/tools/maps.js' },
{ category: 'youtube', specifier: '../engine/tools/youtube.js' },
{ category: 'pieces', specifier: '../engine/tools/pieces.js' },
{ category: 'amazon', specifier: '../engine/tools/amazon.js' },
{ category: 'speech', specifier: '../engine/tools/speech.js' },
{ category: 'checklist', specifier: '../engine/tools/checklist.js' },
{ category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' },
{ category: 'slide', specifier: '../engine/tools/slide.js' },
{ category: 'docs', specifier: '../engine/tools/docs.js' },
{ category: 'mission', specifier: '../engine/tools/mission.js' },
{ category: 'user-folder', specifier: '../engine/tools/user-folder.js' },
{ category: 'brainstorm', specifier: '../engine/tools/brainstorm.js' },
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh-console.js' },
{ category: 'skills', specifier: '../engine/tools/skills.js' },
];
interface ToolModule {
TOOL_DEFS?: Record<string, ToolDef>;
ALL_TOOL_DEFS?: Record<string, ToolDef>;
}
// META_TOOLS, MODULE_SPECS, and ToolModule are imported from tool-categories.ts above.
/**
* Deps the catalog needs to enumerate per-user MCP tools. Optional if

View File

@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { loadConfig } from './config.js';
import { ConfigManager } from './config-manager.js';
// New coverage for the env-override matrix (inventory gaps CFG-004, CFG-016).
// Only OLLAMA_BASE_URL was previously asserted (config.audit-regression). Here we
// drive the REAL loadConfig() for CONCURRENCY / WORKTREE_DIR / OLLAMA_MODEL, and
// the REAL ConfigManager for DB_PATH (which only surfaces as an overriddenByEnv
// flag — it is not consumed by loadConfig).
//
// Env hygiene: snapshot the keys we touch in beforeEach, fully restore in
// afterEach so nothing leaks across tests or files.
const MANAGED_ENV = [
'CONCURRENCY',
'WORKTREE_DIR',
'OLLAMA_MODEL',
'OLLAMA_BASE_URL',
'DB_PATH',
'AAO_CONFIG',
] as const;
function withTempConfig(yaml: string, fn: (path: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), 'maestro-cfg-env-'));
try {
const p = join(dir, 'config.yaml');
writeFileSync(p, yaml);
fn(p);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe('config env-override matrix', () => {
const snapshot: Record<string, string | undefined> = {};
beforeEach(() => {
// Snapshot then clear so each test starts from a known no-env baseline.
for (const k of MANAGED_ENV) {
snapshot[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of MANAGED_ENV) {
const v = snapshot[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
// --- CONCURRENCY -----------------------------------------------------------
describe('CONCURRENCY', () => {
it('overrides config.concurrency when set to a valid integer', () => {
withTempConfig('config_version: 2\nconcurrency: 2\n', (p) => {
process.env['CONCURRENCY'] = '7';
expect(loadConfig(p).concurrency).toBe(7);
});
});
it('falls back to the YAML value when unset', () => {
withTempConfig('config_version: 2\nconcurrency: 4\n', (p) => {
expect(loadConfig(p).concurrency).toBe(4);
});
});
it('falls back to the default (1) when neither env nor YAML provides it', () => {
withTempConfig('config_version: 2\n', (p) => {
expect(loadConfig(p).concurrency).toBe(1);
});
});
it('ignores a non-numeric CONCURRENCY and keeps the YAML value', () => {
withTempConfig('config_version: 2\nconcurrency: 3\n', (p) => {
process.env['CONCURRENCY'] = 'not-a-number';
expect(loadConfig(p).concurrency).toBe(3);
});
});
});
// --- WORKTREE_DIR ----------------------------------------------------------
describe('WORKTREE_DIR', () => {
it('overrides both config.worktreeDir and config.storage.worktreeDir when set', () => {
withTempConfig('config_version: 2\nstorage:\n worktree_dir: /yaml/wt\n', (p) => {
process.env['WORKTREE_DIR'] = '/env/override/wt';
const config = loadConfig(p);
expect(config.worktreeDir).toBe('/env/override/wt');
expect(config.storage?.worktreeDir).toBe('/env/override/wt');
});
});
it('falls back to the YAML storage value when unset', () => {
withTempConfig('config_version: 2\nstorage:\n worktree_dir: /yaml/wt\n', (p) => {
expect(loadConfig(p).worktreeDir).toBe('/yaml/wt');
});
});
it('falls back to the default worktreeDir when neither env nor YAML provides it', () => {
withTempConfig('config_version: 2\n', (p) => {
expect(loadConfig(p).worktreeDir).toBe('/var/lib/maestro/workspaces');
});
});
});
// --- OLLAMA_MODEL ----------------------------------------------------------
describe('OLLAMA_MODEL', () => {
it('overrides provider.model and the first provider worker model when set', () => {
withTempConfig(
'config_version: 2\nllm:\n workers:\n - name: w1\n endpoint: http://h:11434/v1\n model: yaml-model\n',
(p) => {
process.env['OLLAMA_MODEL'] = 'env-model';
const config = loadConfig(p);
expect(config.provider.model).toBe('env-model');
// post-normalize override targets the executed provider.workers[0]
expect(config.provider.workers[0]?.model).toBe('env-model');
},
);
});
it('does NOT bake the env override into the persisted llm block', () => {
withTempConfig(
'config_version: 2\nllm:\n workers:\n - name: w1\n endpoint: http://h:11434/v1\n model: yaml-model\n',
(p) => {
process.env['OLLAMA_MODEL'] = 'env-model';
const config = loadConfig(p);
// llm.workers (the block config-manager persists) must keep the YAML value.
expect(config.llm.workers[0]?.model).toBe('yaml-model');
},
);
});
it('falls back to the default provider model (qwen3:32b) when unset', () => {
withTempConfig('config_version: 2\n', (p) => {
expect(loadConfig(p).provider.model).toBe('qwen3:32b');
});
});
});
// --- DB_PATH (surfaces only as an overriddenByEnv flag) --------------------
describe('DB_PATH', () => {
it('flags dbPath as env-overridden in ConfigManager.getConfigForApi when set', () => {
withTempConfig('config_version: 2\n', (p) => {
process.env['DB_PATH'] = '/env/override/maestro.db';
const mgr = new ConfigManager(p);
const { overriddenByEnv } = mgr.getConfigForApi();
expect(overriddenByEnv['dbPath']).toBe(true);
});
});
it('does NOT flag dbPath when DB_PATH is unset', () => {
withTempConfig('config_version: 2\n', (p) => {
const mgr = new ConfigManager(p);
const { overriddenByEnv } = mgr.getConfigForApi();
expect(overriddenByEnv['dbPath']).toBeUndefined();
});
});
});
// --- Cross-check: the documented flags appear for each env var -------------
describe('overriddenByEnv flag matrix (config-manager)', () => {
it('flags concurrency, storage.worktreeDir, and llm.workers[0].model together', () => {
withTempConfig('config_version: 2\n', (p) => {
process.env['CONCURRENCY'] = '3';
process.env['WORKTREE_DIR'] = '/env/wt';
process.env['OLLAMA_MODEL'] = 'env-model';
const mgr = new ConfigManager(p);
const { overriddenByEnv } = mgr.getConfigForApi();
expect(overriddenByEnv['concurrency']).toBe(true);
expect(overriddenByEnv['storage.worktreeDir']).toBe(true);
expect(overriddenByEnv['llm.workers[0].model']).toBe(true);
});
});
});
});

View File

@ -237,6 +237,7 @@ export interface HistorySummarizationConfig {
enabled?: boolean; // default true
tailTurns?: number; // default 2 (assistant+tool turns to always preserve)
preserveRecentBudget?: number; // default 8000 tokens
reserveCapTokens?: number; // default 32000; caps the headroom reserved above the prompt before compact-and-continue triggers (lets large-context models use their headroom)
}
export interface SafetyConfig {
@ -974,6 +975,11 @@ export function validateConfig(config: AppConfig): string[] {
errors.push('safety.historySummarization.preserveRecentBudget must be a positive integer if defined');
}
}
if (hs.reserveCapTokens !== undefined) {
if (!Number.isInteger(hs.reserveCapTokens) || hs.reserveCapTokens <= 0) {
errors.push('safety.historySummarization.reserveCapTokens must be a positive integer if defined');
}
}
}
if (config.safety.bashUnrestricted !== undefined && typeof config.safety.bashUnrestricted !== 'boolean') {
errors.push('safety.bashUnrestricted must be a boolean if defined');

View File

@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from './repository.js';
/**
* Phase 8 (API-key auth + user-owned MCP servers) added auth_kind /
* static_token_enc / auth_header_name / owner_id to mcp_servers via migrate.ts.
* These must also exist on the fresh-DB path (schema.sql, run by
* Repository.initSchema) otherwise a DB created without runMigrations lacks
* the columns. This guards the dual-path rule (project_db_migration_dual_path).
*/
function hasColumn(db: Database.Database, table: string, column: string): boolean {
const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>;
return cols.some((c) => c.name === column);
}
describe('mcp_servers auth columns: Repository.initSchema (schema.sql)', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
function makeRepo(): Repository {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-mcpauth-'));
return new Repository(join(tempDir, 'orchestrator.db'));
}
it('has auth_kind / static_token_enc / auth_header_name / owner_id on a fresh Repository', () => {
const db = makeRepo().getDb();
expect(hasColumn(db, 'mcp_servers', 'auth_kind')).toBe(true);
expect(hasColumn(db, 'mcp_servers', 'static_token_enc')).toBe(true);
expect(hasColumn(db, 'mcp_servers', 'auth_header_name')).toBe(true);
expect(hasColumn(db, 'mcp_servers', 'owner_id')).toBe(true);
});
});

View File

@ -141,8 +141,25 @@ export function runMigrations(db: Database.Database): void {
migrateSpaceMembers(db);
migrateCalendarEvents(db);
migrateSpaceInvites(db);
migrateAppShareLinks(db);
migratePrivatizeSpaceRows(db);
migrateRenamePersonalSpaceTitle(db);
migrateSpacesToolPolicy(db);
}
/**
* spaces.tool_policy nullable JSON
*
* 冪等: カラムが既に存在する場合は何もしない
* Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
*/
function migrateSpacesToolPolicy(db: Database.Database): void {
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
if (!exists) return;
const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'tool_policy')) {
db.exec("ALTER TABLE spaces ADD COLUMN tool_policy TEXT");
}
}
/**
@ -321,6 +338,10 @@ function migrateCalendarEvents(db: Database.Database): void {
addColumnIfMissing(db, 'calendar_events', 'end_date', () => {
db.exec("ALTER TABLE calendar_events ADD COLUMN end_date TEXT");
});
// 終了時刻: end_timeNULL=終了時刻なし。time が NULL なら常に NULL。追加のみ・冪等。
addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT");
});
}
/**
@ -344,6 +365,22 @@ function migrateSpaceInvites(db: Database.Database): void {
`);
}
function migrateAppShareLinks(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS app_share_links (
token TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
app_name TEXT NOT NULL,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_app_share_links_space_app
ON app_share_links(space_id, app_name);
`);
}
/**
* Per-space MCP isolation migration (Phase 2). Idempotent.
*

View File

@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from './repository.js';
describe('app_share_links repository methods', () => {
let repo: Repository;
let dir: string;
let spaceId: string;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'app-share-repo-'));
repo = new Repository(join(dir, `${randomUUID()}.db`));
// 本物の space を 1 件作るFK 制約 spaces(id) を満たすため)。
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: 'user-1' });
spaceId = space.id;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('createAppShareLink → resolveAppShareToken で往復できる', () => {
const { token } = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
const resolved = repo.resolveAppShareToken(token);
expect(resolved).toEqual({ spaceId, appName: 'dashboard' });
});
it('未失効の重複作成は同一トークンを再利用する', () => {
const first = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
const second = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
expect(second.token).toBe(first.token);
});
it('別アプリは別トークンを発行する', () => {
const a = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
const b = repo.createAppShareLink(spaceId, 'reporter', 'user-1');
expect(b.token).not.toBe(a.token);
});
it('getAppShareLink は現行リンクの状態を返す', () => {
expect(repo.getAppShareLink(spaceId, 'dashboard')).toBeNull();
const { token } = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
const got = repo.getAppShareLink(spaceId, 'dashboard');
expect(got).not.toBeNull();
expect(got!.token).toBe(token);
expect(got!.revokedAt).toBeNull();
});
it('revokeAppShareLink 後は resolveAppShareToken が null を返す', () => {
const { token } = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
repo.revokeAppShareLink(spaceId, 'dashboard');
expect(repo.resolveAppShareToken(token)).toBeNull();
});
it('revoke 後の getAppShareLink は revokedAt を持つ', () => {
repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
repo.revokeAppShareLink(spaceId, 'dashboard');
const got = repo.getAppShareLink(spaceId, 'dashboard');
expect(got).not.toBeNull();
expect(got!.revokedAt).not.toBeNull();
});
it('失効後の再作成は新しいトークンを発行する(古いトークンは再利用しない)', () => {
const first = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
repo.revokeAppShareLink(spaceId, 'dashboard');
const second = repo.createAppShareLink(spaceId, 'dashboard', 'user-1');
expect(second.token).not.toBe(first.token);
// 古いトークンは失効済みのまま resolve 不可
expect(repo.resolveAppShareToken(first.token)).toBeNull();
// 新トークンは有効
expect(repo.resolveAppShareToken(second.token)).toEqual({ spaceId, appName: 'dashboard' });
});
it('不正トークンは resolveAppShareToken が null を返す', () => {
expect(repo.resolveAppShareToken('nonexistent')).toBeNull();
});
it('createdBy が null でも作成できる', () => {
const { token } = repo.createAppShareLink(spaceId, 'dashboard', null);
expect(repo.resolveAppShareToken(token)).toEqual({ spaceId, appName: 'dashboard' });
});
});

View File

@ -224,6 +224,37 @@ describe('Repository calendar_events', () => {
});
});
describe('end_time (startend time range)', () => {
it('stores time + endTime and reads them back', async () => {
const ev = await repo.createCalendarEvent({ spaceId: 'S', date: '2026-06-20', time: '09:00', endTime: '10:30', title: 'mtg' });
expect(ev.time).toBe('09:00');
expect(ev.endTime).toBe('10:30');
const got = await repo.getCalendarEvent(ev.id);
expect(got?.endTime).toBe('10:30');
});
it('drops endTime when there is no start time (all-day)', async () => {
const ev = await repo.createCalendarEvent({ spaceId: 'S', date: '2026-06-20', endTime: '10:30', title: 'allday' });
expect(ev.time).toBeNull();
expect(ev.endTime).toBeNull();
});
it('updateCalendarEvent can set and clear endTime', async () => {
const ev = await repo.createCalendarEvent({ spaceId: 'S', date: '2026-06-20', time: '09:00', title: 'e' });
const set = await repo.updateCalendarEvent(ev.id, { endTime: '11:00' });
expect(set?.endTime).toBe('11:00');
const cleared = await repo.updateCalendarEvent(ev.id, { endTime: null });
expect(cleared?.endTime).toBeNull();
});
it('clearing the start time also clears endTime (no orphan end time)', async () => {
const ev = await repo.createCalendarEvent({ spaceId: 'S', date: '2026-06-20', time: '09:00', endTime: '10:00', title: 'e' });
const cleared = await repo.updateCalendarEvent(ev.id, { time: null });
expect(cleared?.time).toBeNull();
expect(cleared?.endTime).toBeNull();
});
});
describe('getSpaceCalendarDay', () => {
it('returns tasks created that local day + events for that date', async () => {
const space = await repo.createSpace({ kind: 'case', title: 'sp', ownerId: 'u1', visibility: 'private' });

View File

@ -0,0 +1,406 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from './repository.js';
// New functional coverage for the job recovery / resume helpers, the audit-log
// writer, the comment-injection lifecycle, and the subtask-ASK requeue plumbing.
// All exercise the real Repository against a temp-file SQLite DB (schema +
// migrations applied by the constructor). See inventory gaps DB-003, DB-032,
// DB-027, DB-008, SVC-019.
describe('Repository job recovery / resume / comments', () => {
let tempDir = '';
function makeRepo(): Repository {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-recovery-'));
return new Repository(join(tempDir, 'orchestrator.db'));
}
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
// --- recoverOrphanedJobs ---------------------------------------------------
describe('recoverOrphanedJobs', () => {
it('requeues running and dispatching jobs and clears issue locks', async () => {
const repo = makeRepo();
try {
const running = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
const dispatching = await repo.createJob({ repo: 'acme/b', issueNumber: 2, instruction: 'b' });
const queued = await repo.createJob({ repo: 'acme/c', issueNumber: 3, instruction: 'c' });
await repo.updateJob(running.id, { status: 'running' });
await repo.updateJob(dispatching.id, { status: 'dispatching' });
await repo.lockIssue('acme/a', 1, running.id);
const changes = await repo.recoverOrphanedJobs();
expect(changes).toBe(2);
expect((await repo.getJob(running.id))?.status).toBe('queued');
expect((await repo.getJob(dispatching.id))?.status).toBe('queued');
// queued job untouched
expect((await repo.getJob(queued.id))?.status).toBe('queued');
// worker_id cleared
expect((await repo.getJob(running.id))?.workerId).toBeNull();
// issue lock released, so re-locking succeeds
expect(await repo.lockIssue('acme/a', 1, dispatching.id)).toBe(true);
} finally {
repo.close();
}
});
it('is a no-op (returns 0) when nothing is running or dispatching', async () => {
const repo = makeRepo();
try {
const queued = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
expect(await repo.recoverOrphanedJobs()).toBe(0);
expect((await repo.getJob(queued.id))?.status).toBe('queued');
} finally {
repo.close();
}
});
it('requeues a waiting_subtasks parent once all its latest subtasks are terminal', async () => {
const repo = makeRepo();
try {
const parent = await repo.createJob({ repo: 'acme/p', issueNumber: 10, instruction: 'parent' });
await repo.updateJob(parent.id, { status: 'waiting_subtasks' });
const sub = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 100, instruction: 'child', parentJobId: parent.id,
});
await repo.updateJob(sub.id, { status: 'succeeded' });
await repo.recoverOrphanedJobs();
expect((await repo.getJob(parent.id))?.status).toBe('queued');
} finally {
repo.close();
}
});
it('leaves a waiting_subtasks parent parked while a subtask is still in flight', async () => {
const repo = makeRepo();
try {
const parent = await repo.createJob({ repo: 'acme/p', issueNumber: 11, instruction: 'parent' });
await repo.updateJob(parent.id, { status: 'waiting_subtasks' });
const sub = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 101, instruction: 'child', parentJobId: parent.id,
});
await repo.updateJob(sub.id, { status: 'running' });
await repo.recoverOrphanedJobs();
expect((await repo.getJob(parent.id))?.status).toBe('waiting_subtasks');
} finally {
repo.close();
}
});
});
// --- recoverStuckRunningJobs ----------------------------------------------
describe('recoverStuckRunningJobs', () => {
it('requeues only running/dispatching jobs whose updated_at is older than staleMinutes', async () => {
const repo = makeRepo();
try {
const stale = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
const fresh = await repo.createJob({ repo: 'acme/b', issueNumber: 2, instruction: 'b' });
await repo.updateJob(stale.id, { status: 'running' });
await repo.updateJob(fresh.id, { status: 'running' });
await repo.lockIssue('acme/a', 1, stale.id);
// Backdate the stale job's updated_at by 30 minutes via the public db handle.
repo.getDb()
.prepare("UPDATE jobs SET updated_at = datetime('now', '-30 minutes') WHERE id = ?")
.run(stale.id);
const recovered = repo.recoverStuckRunningJobs(10);
expect(recovered).toBe(1);
const staleJob = await repo.getJob(stale.id);
expect(staleJob?.status).toBe('queued');
expect(staleJob?.workerId).toBeNull();
expect(staleJob?.errorSummary).toContain('stuck in running');
// fresh job is still running (updated_at is recent)
expect((await repo.getJob(fresh.id))?.status).toBe('running');
// the stuck job's issue lock was released
expect(await repo.lockIssue('acme/a', 1, fresh.id)).toBe(true);
} finally {
repo.close();
}
});
it('is a no-op (returns 0) when no job is stale', async () => {
const repo = makeRepo();
try {
const running = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
await repo.updateJob(running.id, { status: 'running' });
// updated_at is now → not stale for any positive threshold
expect(repo.recoverStuckRunningJobs(10)).toBe(0);
expect((await repo.getJob(running.id))?.status).toBe('running');
} finally {
repo.close();
}
});
});
// --- resumeMcpWaitingJobs --------------------------------------------------
describe('resumeMcpWaitingJobs', () => {
it('requeues only the owner\'s jobs parked on mcp_auth_required', async () => {
const repo = makeRepo();
try {
const mine = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a', ownerId: 'u1' });
const theirs = await repo.createJob({ repo: 'acme/b', issueNumber: 2, instruction: 'b', ownerId: 'u2' });
const otherReason = await repo.createJob({ repo: 'acme/c', issueNumber: 3, instruction: 'c', ownerId: 'u1' });
await repo.updateJob(mine.id, { status: 'waiting_human', waitReason: 'mcp_auth_required' });
await repo.updateJob(theirs.id, { status: 'waiting_human', waitReason: 'mcp_auth_required' });
await repo.updateJob(otherReason.id, { status: 'waiting_human', waitReason: 'tool_request' });
const changes = repo.resumeMcpWaitingJobs('u1', 'server-x');
expect(changes).toBe(1);
const mineJob = await repo.getJob(mine.id);
expect(mineJob?.status).toBe('queued');
expect(mineJob?.waitReason).toBeNull();
// other owner untouched
expect((await repo.getJob(theirs.id))?.status).toBe('waiting_human');
// different wait_reason untouched
expect((await repo.getJob(otherReason.id))?.status).toBe('waiting_human');
} finally {
repo.close();
}
});
it('is a no-op (returns 0) when the owner has no mcp-parked jobs', async () => {
const repo = makeRepo();
try {
const j = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a', ownerId: 'u1' });
await repo.updateJob(j.id, { status: 'running' });
expect(repo.resumeMcpWaitingJobs('u1', 'server-x')).toBe(0);
} finally {
repo.close();
}
});
});
// --- resumeToolRequestJob --------------------------------------------------
describe('resumeToolRequestJob', () => {
it('requeues a job parked on tool_request and clears wait_reason', async () => {
const repo = makeRepo();
try {
const job = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
await repo.updateJob(job.id, { status: 'waiting_human', waitReason: 'tool_request' });
const changes = repo.resumeToolRequestJob(job.id);
expect(changes).toBe(1);
const resumed = await repo.getJob(job.id);
expect(resumed?.status).toBe('queued');
expect(resumed?.waitReason).toBeNull();
} finally {
repo.close();
}
});
it('is a no-op (returns 0) for an unknown id or a job parked for another reason', async () => {
const repo = makeRepo();
try {
expect(repo.resumeToolRequestJob('does-not-exist')).toBe(0);
const job = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
await repo.updateJob(job.id, { status: 'waiting_human', waitReason: 'mcp_auth_required' });
expect(repo.resumeToolRequestJob(job.id)).toBe(0);
expect((await repo.getJob(job.id))?.status).toBe('waiting_human');
} finally {
repo.close();
}
});
});
// --- requeueParentJobIfAllSubtasksDone ------------------------------------
describe('requeueParentJobIfAllSubtasksDone', () => {
it('requeues the parent and returns true once all latest subtasks are terminal', async () => {
const repo = makeRepo();
try {
const parent = await repo.createJob({ repo: 'acme/p', issueNumber: 20, instruction: 'parent' });
await repo.updateJob(parent.id, { status: 'waiting_subtasks' });
const s1 = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 200, instruction: 's1', parentJobId: parent.id,
});
const s2 = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 201, instruction: 's2', parentJobId: parent.id,
});
await repo.updateJob(s1.id, { status: 'succeeded' });
await repo.updateJob(s2.id, { status: 'failed' });
const requeued = await repo.requeueParentJobIfAllSubtasksDone(parent.id);
expect(requeued).toBe(true);
expect((await repo.getJob(parent.id))?.status).toBe('queued');
} finally {
repo.close();
}
});
it('returns false and leaves the parent parked while a subtask is still pending', async () => {
const repo = makeRepo();
try {
const parent = await repo.createJob({ repo: 'acme/p', issueNumber: 21, instruction: 'parent' });
await repo.updateJob(parent.id, { status: 'waiting_subtasks' });
const s1 = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 210, instruction: 's1', parentJobId: parent.id,
});
const s2 = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 211, instruction: 's2', parentJobId: parent.id,
});
await repo.updateJob(s1.id, { status: 'succeeded' });
await repo.updateJob(s2.id, { status: 'running' });
const requeued = await repo.requeueParentJobIfAllSubtasksDone(parent.id);
expect(requeued).toBe(false);
expect((await repo.getJob(parent.id))?.status).toBe('waiting_subtasks');
} finally {
repo.close();
}
});
it('returns false when the parent is not in waiting_subtasks', async () => {
const repo = makeRepo();
try {
const parent = await repo.createJob({ repo: 'acme/p', issueNumber: 22, instruction: 'parent' });
// parent is queued, not waiting_subtasks → guard rejects
const s1 = await repo.createJob({
repo: 'subtask/' + parent.id, issueNumber: 220, instruction: 's1', parentJobId: parent.id,
});
await repo.updateJob(s1.id, { status: 'succeeded' });
expect(await repo.requeueParentJobIfAllSubtasksDone(parent.id)).toBe(false);
expect((await repo.getJob(parent.id))?.status).toBe('queued');
} finally {
repo.close();
}
});
it('uses only the latest job per subtask issue_number when judging completion', async () => {
// A subtask issue that was retried: an OLD failed row + a NEWER running row.
// ROW_NUMBER=1 (latest) is running → parent must stay parked.
const repo = makeRepo();
try {
const parent = await repo.createJob({ repo: 'acme/p', issueNumber: 23, instruction: 'parent' });
await repo.updateJob(parent.id, { status: 'waiting_subtasks' });
const subRepo = 'subtask/' + parent.id;
const old = await repo.createJob({ repo: subRepo, issueNumber: 230, instruction: 'old', parentJobId: parent.id });
await repo.updateJob(old.id, { status: 'failed' });
// newer row for the same issue_number
const fresh = await repo.createJob({ repo: subRepo, issueNumber: 230, instruction: 'fresh', parentJobId: parent.id });
await repo.updateJob(fresh.id, { status: 'running' });
expect(await repo.requeueParentJobIfAllSubtasksDone(parent.id)).toBe(false);
expect((await repo.getJob(parent.id))?.status).toBe('waiting_subtasks');
} finally {
repo.close();
}
});
});
// --- addAuditLog -----------------------------------------------------------
describe('addAuditLog', () => {
it('writes a queryable audit row with the expected fields', async () => {
const repo = makeRepo();
try {
const job = await repo.createJob({ repo: 'acme/a', issueNumber: 1, instruction: 'a' });
await repo.addAuditLog(job.id, 'tool.grant', 'user:u1', { tool: 'Bash', decision: 'approve' });
const row = repo.getDb()
.prepare('SELECT job_id, action, actor, detail FROM audit_log WHERE action = ?')
.get('tool.grant') as { job_id: string; action: string; actor: string; detail: string };
expect(row.job_id).toBe(job.id);
expect(row.action).toBe('tool.grant');
expect(row.actor).toBe('user:u1');
expect(JSON.parse(row.detail)).toEqual({ tool: 'Bash', decision: 'approve' });
} finally {
repo.close();
}
});
it('accepts a null job_id (system-level audit row)', async () => {
const repo = makeRepo();
try {
await repo.addAuditLog(null, 'config.update', 'admin', { changed: ['concurrency'] });
const row = repo.getDb()
.prepare('SELECT job_id, action FROM audit_log WHERE action = ?')
.get('config.update') as { job_id: string | null; action: string };
expect(row.job_id).toBeNull();
expect(row.action).toBe('config.update');
} finally {
repo.close();
}
});
});
// --- Comment injection lifecycle ------------------------------------------
describe('comment injection lifecycle', () => {
it('inject → getUninjectedComments → markCommentsInjected → none; latest result comment', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: 't', body: 'b' });
// Only user comments that are not yet injected should surface.
const c1 = await repo.addLocalTaskComment(task.id, 'user', 'first question');
await repo.addLocalTaskComment(task.id, 'agent', 'progress note', 'comment');
const c2 = await repo.addLocalTaskComment(task.id, 'user', 'second question');
const uninjected = await repo.getUninjectedComments(task.id);
expect(uninjected.map((c) => c.id)).toEqual([c1.id, c2.id]);
repo.markCommentsInjected([c1.id, c2.id]);
expect(await repo.getUninjectedComments(task.id)).toHaveLength(0);
// sinceId filter: only comments with id > sinceId returned.
const c3 = await repo.addLocalTaskComment(task.id, 'user', 'third question');
const afterC2 = await repo.getUninjectedComments(task.id, c2.id);
expect(afterC2.map((c) => c.id)).toEqual([c3.id]);
} finally {
repo.close();
}
});
it('getLatestResultComment returns the newest agent result/ask, null when none', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: 't', body: 'b' });
// No result/ask comment yet.
expect(await repo.getLatestResultComment(task.id)).toBeNull();
await repo.addLocalTaskComment(task.id, 'agent', 'plain comment', 'comment');
await repo.addLocalTaskComment(task.id, 'user', 'ignored user comment', 'result');
// Force a strictly later created_at so the ORDER BY DESC is deterministic.
repo.getDb()
.prepare(
`INSERT INTO local_task_comments (task_id, author, kind, body, created_at)
VALUES (?, 'agent', 'result', 'final answer', datetime('now', '+1 second'))`,
)
.run(task.id);
const latest = await repo.getLatestResultComment(task.id);
expect(latest?.kind).toBe('result');
expect(latest?.body).toBe('final answer');
} finally {
repo.close();
}
});
});
});

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