Compare commits

...
Author SHA1 Message Date
timmy 9118b69057 feat: graceful shutdown, disconnect-driven cancellation, and queue acceptance coverage
Quality gates / quality (pull_request) Successful in 1m58s
- server.mjs tracks in-flight chats: browser disconnects abort the
  queued turn (releasing its slot), and SIGTERM/SIGINT cancels every
  in-flight turn so no Hermes subprocess is orphaned.
- vision-service forwards a per-request AbortSignal combined with the
  provider deadline via AbortSignal.any.
- acceptance suites cover overload fallback, urgent bypass under
  saturation, disconnect cleanup, recovery, and shutdown orphan checks.
- zero-call invariant tests prove urgent text never reaches Hermes or
  the vision provider under load.
2026-08-22 23:01:20 +00:00
timmy 5dcaaae4d6 feat: queue agent turns with cancellation and orphan-free timeout kills
Chat turns now flow through the bounded inference queue: FIFO with
configurable depth (TIMMY_AGENT_MAX_QUEUE_DEPTH) and concurrency
(TIMMY_AGENT_MAX_CONCURRENT_TURNS), sanitized overload/timeout
fallbacks, browser-disconnect cancellation via AbortSignal, and child
process SIGTERM/SIGKILL teardown so no Hermes subprocess survives a
cancelled turn.
2026-08-22 22:04:17 +00:00
timmy 38a59c8ee9 feat: bounded inference queue with deterministic cancellation
Shared primitive for Timmy #17: concurrency slots, FIFO max-depth
overload rejection with sanitized manual-fallback copy, queue-deadline
expiry, per-request AbortSignal delivery, immediate slot release on
external cancel.
2026-08-22 21:39:55 +00:00
timmy 47294a98aa Merge pull request 'Make staging runtime configuration compatible with strict CSP' (#59) from timmy/58-strict-csp-runtime-config into main
Quality gates / quality (push) Successful in 1m56s
2026-08-21 15:04:43 +00:00
timmy 877b8a01f3 fix: make staging compatible with strict CSP
Quality gates / quality (pull_request) Successful in 1m37s
2026-08-21 15:01:05 +00:00
timmy 067afaed4c Merge pull request 'Hardened atomic private-staging deployment tooling' (#56) from timmy/55-staging-deployment-tooling into main
Quality gates / quality (push) Successful in 1m41s
2026-08-21 14:54:11 +00:00
timmy f8f01c4b2e feat: add hardened atomic staging deployment
Quality gates / quality (pull_request) Successful in 1m28s
2026-08-21 14:49:28 +00:00
timmy fc763fb758 Merge pull request 'Private subpage staging foundation' (#54) from timmy/53-private-subpage-staging into main
Quality gates / quality (push) Successful in 2m7s
2026-08-21 14:16:15 +00:00
timmy 7e11154302 feat: add private subpage staging slice
Quality gates / quality (pull_request) Successful in 1m43s
2026-08-21 14:12:17 +00:00
timmy 11d1b364ab fix: gate bootstrap syntax in release build
Quality gates / quality (push) Successful in 1m45s
2026-08-21 12:56:48 +00:00
rockachopa 8e3c2f8889 Merge pull request #52 from timmy/10-reproducible-smolvlm-bootstrap
Quality gates / quality (push) Has been cancelled
Merge pull request #52: Package pinned self-hosted SmolVLM2 bootstrap
2026-08-21 12:55:18 +00:00
timmy 982fab273c feat: package pinned self-host vision bootstrap
Quality gates / quality (pull_request) Successful in 1m30s
2026-08-21 08:15:32 +00:00
timmy ca31e6d38b Merge pull request 'Hotfix: recognize past-tense vomiting in deterministic chat safety' (#51) from timmy/48-vomiting-phrase-hotfix into main
Quality gates / quality (push) Successful in 1m26s
2026-08-20 17:17:33 +00:00
38 changed files with 2944 additions and 76 deletions

No files matched your search

+6 -2
View File
@@ -30,7 +30,9 @@ jobs:
- name: Install browser
run: npx playwright install --with-deps chromium
- name: Unit and security tests
run: npm test
run: |
npm test
python3 tests/staging-deploy.test.py -v
- name: Mobile browser acceptance
run: |
npm start > /tmp/timmy-server.log 2>&1 &
@@ -52,6 +54,8 @@ jobs:
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
run: npm run check:syntax
run: |
npm run check:syntax
node --check tests/staging.acceptance.mjs
- name: Diff hygiene
run: npm run check:diff
+26 -13
View File
@@ -15,28 +15,40 @@ python3 scripts/build_release.py
The builder clones the committed `main` tree into an isolated directory, runs unit/security and both mobile acceptance suites, audits dependencies, checks syntax and secrets, excludes model weights and sensitive/generated media, records a vertical feature demonstration from the working app, fully decodes and probes that MP4, and writes checksummed source/video artifacts plus `manifest.json` under `/root/timmy-releases/`. It refuses dirty or non-`main` source trees.
## Promote or roll back private staging
The standard-library deployment tool verifies the declared archive SHA-256 before tar parsing, rejects unsafe or secret-bearing members, creates one immutable `releases/<40-character-commit>` directory, and atomically repoints `current`. Restart, bounded commit-specific health, or synthetic mobile smoke failures restore and verify the prior release automatically.
```bash
python3 scripts/deploy_staging.py --dry-run promote --tag TAG --archive FILE --sha256 HASH --commit FULL_COMMIT
sudo python3 scripts/deploy_staging.py promote --tag TAG --archive FILE --sha256 HASH --commit FULL_COMMIT
python3 scripts/deploy_staging.py status
sudo python3 scripts/deploy_staging.py rollback --commit PRIOR_FULL_COMMIT
```
Commands are fixed argv arrays executed with `shell=False`; JSON argv overrides and `--root` exist for rootless fixtures. See [the staging runbook](docs/STAGING-RUNBOOK.md) for host prerequisites, mode-600 environment injection, Caddy validation, smoke credentials, logs, backups, removal, and the explicit no-live-change boundary.
## Run with the self-hosted open-weight path
```bash
npm install
git clone --depth 1 https://github.com/ggml-org/llama.cpp /root/model-spikes/llama.cpp
cmake -S /root/model-spikes/llama.cpp -B /root/model-spikes/llama.cpp/build -DCMAKE_BUILD_TYPE=Release
cmake --build /root/model-spikes/llama.cpp/build -j4 --target llama-server
# Installs the exact llama.cpp commit and verifies both GGUF SHA-256 receipts.
scripts/bootstrap_selfhost_smolvlm.sh install
scripts/bootstrap_selfhost_smolvlm.sh start
scripts/bootstrap_selfhost_smolvlm.sh health
hf download ggml-org/SmolVLM2-2.2B-Instruct-GGUF \
SmolVLM2-2.2B-Instruct-Q4_K_M.gguf \
mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf \
--local-dir /root/model-spikes/models/smolvlm2-2.2b
TIMMY_MODEL_PORT=8080 scripts/run_selfhost_smolvlm.sh
# In another terminal
# Run Timmy in another terminal.
TIMMY_VISION_PROFILE=selfhost npm start
# open http://localhost:4173
# When finished:
scripts/bootstrap_selfhost_smolvlm.sh stop
```
The selected bootstrap model is `SmolVLM2-2.2B-Instruct` using the official Apache-2.0 GGUF conversion. The default self-hosted endpoint is loopback-only at `http://127.0.0.1:8080/v1`. Override paths, host, port, threads, model ID, or endpoint with the `TIMMY_MODEL_*` and `TIMMY_VISION_*` environment variables.
The bootstrap defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost`, prints immutable source/model receipts with `receipt`, and binds only to `127.0.0.1:8080`. Downloads are rejected unless both pinned SHA-256 hashes match, and model weights remain outside Git. Set `TIMMY_SELFHOST_ROOT` to choose another private data directory; override host, port, threads, or build jobs with the documented `TIMMY_MODEL_*`/`TIMMY_BUILD_JOBS` environment variables.
The selected bootstrap model is `SmolVLM2-2.2B-Instruct` using the official Apache-2.0 GGUF conversion. It is a provisional labeling worker, not a diagnostic or clinically validated classifier.
## Run with a hosted provider
@@ -120,7 +132,8 @@ Browser PWA
├── src/hermes-agent-service.js authenticated session-bound Hermes CLI adapter
├── src/vision-service.js server-side OpenAI-compatible provider adapter
├── src/vision-config.js hosted/self-hosted profiles and readiness probe
├── scripts/run_selfhost_smolvlm.sh
├── scripts/bootstrap_selfhost_smolvlm.sh pinned install/start/health/stop lifecycle
├── scripts/run_selfhost_smolvlm.sh custom-path foreground launcher
├── scripts/ingest_training_photo.py
├── localStorage saved ledger and attached photos
└── llama.cpp / hosted API selected per server-side profile
+32 -14
View File
@@ -1,7 +1,24 @@
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
import { mergeVisualSuggestion } from './src/analysis.js';
const STORE = 'timmy-ledger-v1';
const runtimeConfig = {
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
stagingLabel: document.querySelector('meta[name="timmy-staging-label"]')?.content || '',
};
const BASE_PATH = runtimeConfig.basePath || '/';
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
const LEGACY_STORE = 'timmy-ledger-v1';
if (BASE_PATH === '/') {
const legacyEntries = localStorage.getItem(LEGACY_STORE);
if (legacyEntries !== null) {
try {
if (!localStorage.getItem(STORE)) localStorage.setItem(STORE, legacyEntries);
localStorage.removeItem(LEGACY_STORE);
} catch {}
}
}
const app = document.querySelector('#app');
let entries = loadEntries();
let view = 'home';
@@ -24,7 +41,8 @@ function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'sh
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
function shell(content) {
app.innerHTML = `<header class="topbar"><div class="brand"><img src="/assets/timmy.svg" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${nav()}`;
const label=runtimeConfig.stagingLabel?`<footer class="staging-label">${esc(runtimeConfig.stagingLabel)}</footer>`:'';
app.innerHTML = `<header class="topbar"><div class="brand"><img src="${appPath('assets/timmy.svg')}" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${label}${nav()}`;
bindGlobal();
}
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
@@ -35,7 +53,7 @@ function bindGlobal(){
}
function recentList(limit=5){
if(!entries.length)return `<div class="empty"><img src="/assets/timmy.svg" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
if(!entries.length)return `<div class="empty"><img src="${appPath('assets/timmy.svg')}" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span></div><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span></article>`).join('');
}
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
@@ -43,7 +61,7 @@ function currentStreak(){const dates=new Set(entries.map(e=>e.occurredAt.slice(0
function home(){
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section><section class="section"><div class="insight-card"><div class="timmy-orb"><img src="/assets/timmy.svg" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p>${esc(buildTimmySummary(entries))}</p></div></div></section><section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section><section class="section"><div class="insight-card"><div class="timmy-orb"><img src="${appPath('assets/timmy.svg')}" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p>${esc(buildTimmySummary(entries))}</p></div></div></section><section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
}
function calendar(){
@@ -69,13 +87,13 @@ function timmy(){
requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight});
}
async function loadAgentStatus(){
try{const response=await fetch('/api/agent/status',{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
try{const response=await fetch(appPath('api/agent/status'),{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
catch{agentStatus={enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
if(view==='timmy')timmy();
}
async function unlockAgent(){
const code=document.querySelector('#agent-code')?.value||'';chatError='';
try{const response=await fetch('/api/agent/unlock',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
try{const response=await fetch(appPath('api/agent/unlock'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
catch(error){chatError=error.message||'Could not connect.';timmy()}
}
function localChatReply(message){
@@ -91,7 +109,7 @@ async function sendChat(event){
const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return}
if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
chatBusy=true;timmy();
try{const response=await fetch('/api/agent/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})}
try{const response=await fetch(appPath('api/agent/chat'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})}
catch(error){chatError=error.message||'Hermes is unavailable.';chatMessages.push({role:'timmy',text:'I could not reach Hermes. Your local journal still works, and nothing was changed.'})}
finally{chatBusy=false;timmy()}
}
@@ -102,11 +120,11 @@ function privacy(){
}
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);render();toast('Local ledger deleted')}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
try{const response=await fetch('/api/vision-status',{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
catch{visionStatus={enabled:false,providerReady:false}}
if(document.querySelector('.scan-sheet')&&['pick','ready'].includes(photoFirstMode))showPhotoFirst(photoFirstMode)
}
@@ -117,9 +135,9 @@ function visionStatusHtml(){
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
}
function photoFirstBody(mode,error=''){
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="/assets/timmy.svg" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
if(mode==='analyzing')return `<div class="analyzing"><img src="/assets/timmy.svg" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
@@ -134,12 +152,12 @@ function showPhotoFirst(mode='pick',error=''){
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)});
}
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch('/api/analyze',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)}
function showLogStep(step){
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i style="width:${step*33.34}%"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
}
function stepBody(step){
@@ -159,4 +177,4 @@ function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=san
function render(){({home,calendar,timmy,privacy}[view]||home)()}
render();
if('serviceWorker' in navigator)navigator.serviceWorker.register('/service-worker.js').catch(()=>{});
if('serviceWorker' in navigator)navigator.serviceWorker.register(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{});
+36
View File
@@ -0,0 +1,36 @@
# Example complete site used for validation. Merge only the reviewed handles into
# the existing forge site. Route order is security-sensitive.
forge.example.invalid {
encode zstd gzip
@git path /git /git/*
handle @git {
reverse_proxy 127.0.0.1:3000
}
handle_path /timmy-staging/* {
basic_auth {
staging {$TIMMY_STAGING_PASSWORD_HASH}
}
request_body {
max_size 8MB
}
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
Permissions-Policy "camera=(self), microphone=(), geolocation=()"
Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
-Server
}
# handle_path strips the public prefix exactly once. Reconstruct it for
# Timmy because its validated base-path router must see that same prefix.
rewrite * /timmy-staging{uri}
reverse_proxy 127.0.0.1:4174
}
# Existing Gitea catchall belongs after the private staging route.
handle {
reverse_proxy 127.0.0.1:3000
}
}
+11
View File
@@ -0,0 +1,11 @@
# Copy with: sudo install -m 600 deploy/timmy-staging.env.example /etc/timmy-staging.env
# Replace release identity for every promotion. Keep this file root-owned and out of archives.
HOST=127.0.0.1
PORT=4174
TIMMY_BASE_PATH=/timmy-staging
TIMMY_STAGING_LABEL=true
TIMMY_RELEASE_TAG=CHANGE_ME
TIMMY_RELEASE_COMMIT=0000000000000000000000000000000000000000
TIMMY_AGENT_ENABLED=false
TIMMY_VISION_ENABLED=false
NODE_ENV=production
+61
View File
@@ -0,0 +1,61 @@
[Unit]
Description=Timmy private staging service
Documentation=file:/opt/timmy-staging/current/docs/STAGING-RUNBOOK.md
After=network.target
[Service]
Type=simple
User=timmy-staging
Group=timmy-staging
UMask=0077
WorkingDirectory=/opt/timmy-staging/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=4174
Environment=TIMMY_BASE_PATH=/timmy-staging
Environment=TIMMY_AGENT_ENABLED=false
Environment=TIMMY_VISION_ENABLED=false
EnvironmentFile=/etc/timmy-staging.env
ExecStart=/usr/bin/env TIMMY_AGENT_ENABLED=false TIMMY_VISION_ENABLED=false /usr/local/lib/timmy-staging/node server.mjs
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=15s
KillSignal=SIGTERM
# Filesystem and privilege boundary. Only state is writable.
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/timmy-staging
StateDirectory=timmy-staging
StateDirectoryMode=0700
RestrictSUIDSGID=true
LockPersonality=true
RestrictNamespaces=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
CapabilityBoundingSet=
AmbientCapabilities=
# Process, syscall, network, and resource boundary.
RestrictRealtime=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ProtectProc=invisible
ProcSubset=pid
TasksMax=64
MemoryMax=512M
CPUQuota=100%
LimitNOFILE=1024
[Install]
WantedBy=multi-user.target
+118
View File
@@ -0,0 +1,118 @@
# Timmy private staging runbook
This runbook is a reviewed host template, not approval to change a live host. Apply it only in a separately approved provisioning change. Staging is Phase 1: Hermes Agent and vision are off.
## Prerequisites
- Linux host with Node.js 22, systemd, and Caddy.
- Existing HTTPS origin and Gitea route inventory.
- A reviewed 40-character commit, source archive, and SHA-256 receipt from the release manifest.
- Root only for one-time account/unit/config installation; promotions use the narrowest available sudo policy.
- A fresh Caddy password hash delivered outside Git. Never put the password or hash in shell history, release notes, screenshots, or this repository.
Confirm that `127.0.0.1:4174` is unused. Do not copy `.env`, credentials, model weights, raw media, or a mutable Git checkout into a release.
## DNS and URL
No DNS change is required for the approved subpage deployment. The private URL is `https://forge.alexanderwhitestone.com/timmy-staging/`. Caddy remains the only public listener; Node binds only to `127.0.0.1:4174`. Verify `/git/` before and after any separately approved Caddy reload.
## Install
These commands are reference commands for an approved maintenance window; they have not been run by this change:
```bash
NODE_SOURCE="$(readlink -f "$(command -v node)")"
"$NODE_SOURCE" --version # must report v22.x before installation
sudo install -D -o root -g root -m 755 "$NODE_SOURCE" /usr/local/lib/timmy-staging/node
/usr/local/lib/timmy-staging/node --version
sudo useradd --system --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin timmy-staging
sudo install -d -o root -g root -m 755 /opt/timmy-staging/releases
sudo install -d -o timmy-staging -g timmy-staging -m 700 /var/lib/timmy-staging
sudo install -o root -g root -m 644 deploy/timmy-staging.service /etc/systemd/system/timmy-staging.service
sudo install -o root -g root -m 600 deploy/timmy-staging.env.example /etc/timmy-staging.env
sudo chmod 600 /etc/timmy-staging.env
sudo systemctl daemon-reload
```
Edit only `TIMMY_RELEASE_TAG` and `TIMMY_RELEASE_COMMIT` for the selected artifact. The unit deliberately applies `TIMMY_AGENT_ENABLED=false` and `TIMMY_VISION_ENABLED=false` on the `ExecStart` command after loading the environment file, so values in that file cannot enable either subsystem. Leave the loopback host, port, and base path unchanged. The environment file must stay root-owned mode 600 and absent from release archives. The copied Node 22 executable is root-owned at `/usr/local/lib/timmy-staging/node`, outside every home directory, so `ProtectHome=true` remains enforceable; replace it only through a separately reviewed Node upgrade.
Generate Caddy basic-auth material interactively (for example, `caddy hash-password`) and inject it as `TIMMY_STAGING_PASSWORD_HASH`; do not commit the output. Merge the staging handle before the existing catchall, validate a temporary complete config, then use `caddy reload` rather than restarting unrelated services.
## Promote an immutable release
First inspect without mutation:
```bash
python3 scripts/deploy_staging.py --dry-run promote \
--tag daily-YYYY-MM-DD.N --archive /secure/inbox/timmy.tar.gz \
--sha256 64_LOWERCASE_HEX_CHARACTERS --commit 40_LOWERCASE_HEX_CHARACTERS
```
Then run the same command without `--dry-run`. Promotion opens only a non-symlink regular source, copies it while hashing into a mode-400 file in a private temporary directory, and inspects and extracts only that verified copy before cleaning it. It rejects unsafe members and forbidden artifacts, extracts once to `/opt/timmy-staging/releases/<commit>`, and never overwrites that directory. It atomically swaps `current`, restarts only `timmy-staging.service`, checks bounded health, and invokes `npm run test:staging-smoke` as an argv array with `shell=False`. Restart, health, or smoke failure automatically restores and verifies the prior symlink. If there is no prior release, the tool removes `current` and stops the service instead of restarting it against a missing path; the failed release remains unreferenced as inert immutable evidence for operator review.
The operator must update `/etc/timmy-staging.env` release identity to the same reviewed tag and commit before promotion. A narrow wrapper/sudo policy may set the smoke environment without granting arbitrary command execution:
```bash
export TIMMY_STAGING_URL=https://forge.alexanderwhitestone.com/timmy-staging/
export TIMMY_STAGING_USER=staging
# Read TIMMY_STAGING_PASSWORD from an approved secret channel; never paste it here.
sudo -E python3 scripts/deploy_staging.py promote --tag "$TAG" --archive "$ARCHIVE" --sha256 "$SHA256" --commit "$COMMIT"
```
## Smoke test
The promotion runs the synthetic 390×844 Playwright suite. It checks edge auth, build identity, one dominant photo action, manual save, urgent-language interception, Journal visibility, export, no horizontal overflow, browser errors, and suspicious secret-bearing responses. It must never use a real stool photo or medical record.
For an explicit rerun:
```bash
TIMMY_STAGING_URL=https://forge.alexanderwhitestone.com/timmy-staging/ \
TIMMY_EXPECT_RELEASE_TAG="$TAG" TIMMY_EXPECT_RELEASE_COMMIT="$COMMIT" \
TIMMY_STAGING_USER=staging TIMMY_STAGING_PASSWORD="$(secret-reader)" \
npm run test:staging-smoke
```
Also verify `curl --fail http://127.0.0.1:4174/timmy-staging/api/healthz`, authenticated public health, unauthenticated HTTP 401, `/git/`, and that no wildcard/public listener owns port 4174.
## Status and logs
```bash
python3 scripts/deploy_staging.py status
sudo systemctl status timmy-staging.service
sudo journalctl -u timmy-staging.service --since today --no-pager
# Only after an approved environment-file change:
sudo systemctl restart timmy-staging.service
```
Health and logs may show bounded release identity, but must not show environment values, cookies, passwords, tokens, session data, filesystem secrets, or raw user content.
## Rollback
List reviewed immutable commit directories, choose the known-good receipt, dry-run, then roll back:
```bash
python3 scripts/deploy_staging.py --dry-run rollback --commit 40_LOWERCASE_HEX_CHARACTERS
sudo python3 scripts/deploy_staging.py rollback --commit 40_LOWERCASE_HEX_CHARACTERS
```
Rollback atomically repoints `current`, restarts only Timmy, and verifies commit-specific loopback health. If rollback verification fails, the tool restores the release that was current when rollback began. Never repoint the symlink manually while the service is running.
## Backup
Browser journal data is local to each browser and is not server state. Before host maintenance, back up only operator-owned receipts and any explicitly required `/var/lib/timmy-staging` state with root-only permissions. Release archives should be recoverable from their verified release source; do not back up `/etc/timmy-staging.env` into a general artifact store. Test restore procedures without live credentials.
## Template validation
```bash
node --test tests/staging-config.test.js
systemd-analyze verify deploy/timmy-staging.service
HASH="$(caddy hash-password --plaintext validation-only-password)"
TIMMY_STAGING_PASSWORD_HASH="$HASH" caddy validate --config deploy/Caddyfile.staging.example --adapter caddyfile
python3 -m py_compile scripts/deploy_staging.py
```
Use only a disposable validation hash. Validation does not authorize installation or reload.
## Remove staging
In a separately approved maintenance window: disable and stop only `timmy-staging.service`; remove only the reviewed Caddy staging handle and validate/reload Caddy; verify `/git/`; remove the unit and run `systemctl daemon-reload`; archive required receipts; then remove `/opt/timmy-staging` and `/var/lib/timmy-staging`. Delete the dedicated locked user last. Do not delete shared Caddy, Gitea, TLS, or browser data.
+5 -2
View File
@@ -4,11 +4,14 @@
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js",
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-queue.test.js tests/agent-turn-timeout.test.js tests/inference-queue.test.js tests/inference-queue-cancellation.test.js tests/inference-zero-call.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py",
"test:queue-acceptance": "node tests/inference-queue.acceptance.mjs",
"test:shutdown-acceptance": "node tests/server-shutdown.acceptance.mjs",
"test:staging-smoke": "node tests/staging.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/inference-queue.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/inference-queue.acceptance.mjs && node --check tests/server-shutdown.acceptance.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs"
},
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -euo pipefail
LLAMA_CPP_COMMIT="${TIMMY_LLAMA_CPP_COMMIT:-6d05498314db1b57f81c271080018aa2d0b89be9}"
MODEL_SHA256="${TIMMY_MODEL_SHA256:-0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58}"
MMPROJ_SHA256="${TIMMY_MMPROJ_SHA256:-ae07ea1facd07dd3230c4483b63e8cda96c6944ad2481f33d531f79e892dd024}"
HOST="${TIMMY_MODEL_HOST:-127.0.0.1}"
PORT="${TIMMY_MODEL_PORT:-8080}"
ROOT="${TIMMY_SELFHOST_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost}"
MODEL_DIR="$ROOT/models"
MODEL="$MODEL_DIR/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf"
MMPROJ="$MODEL_DIR/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf"
SOURCE="$ROOT/llama.cpp"
BUILD="$SOURCE/build"
HF_BASE="https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main"
SERVER="$BUILD/bin/llama-server"
RUNTIME="$ROOT/run"
PIDFILE="$RUNTIME/llama-server.pid"
LOGFILE="$RUNTIME/llama-server.log"
verify_files() {
for file in "$MODEL" "$MMPROJ"; do
if [[ ! -f "$file" ]]; then
printf 'Missing required file: %s. Run %s install.\n' "$file" "$0" >&2
return 1
fi
done
printf '%s %s\n%s %s\n' "$MODEL_SHA256" "$MODEL" "$MMPROJ_SHA256" "$MMPROJ" | sha256sum --check --status
}
download_verified() {
local url="$1" target="$2" expected="$3" temporary="$2.download"
rm -f "$temporary"
curl --fail --location --retry 3 --output "$temporary" "$url"
printf '%s %s\n' "$expected" "$temporary" | sha256sum --check --status || {
rm -f "$temporary"
printf 'Downloaded file failed pinned SHA-256 verification: %s\n' "$target" >&2
return 1
}
mv "$temporary" "$target"
}
install_worker() {
command -v git >/dev/null
command -v cmake >/dev/null
command -v curl >/dev/null
mkdir -p "$ROOT" "$MODEL_DIR"
if [[ ! -d "$SOURCE/.git" ]]; then
git clone https://github.com/ggml-org/llama.cpp "$SOURCE"
fi
git -C "$SOURCE" fetch origin "$LLAMA_CPP_COMMIT"
git -C "$SOURCE" checkout --detach "$LLAMA_CPP_COMMIT"
cmake -S "$SOURCE" -B "$BUILD" -DCMAKE_BUILD_TYPE=Release -DLLAMA_CURL=OFF
cmake --build "$BUILD" --parallel "${TIMMY_BUILD_JOBS:-4}" --target llama-server
download_verified "$HF_BASE/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf" "$MODEL" "$MODEL_SHA256"
download_verified "$HF_BASE/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf" "$MMPROJ" "$MMPROJ_SHA256"
verify_files
printf 'Pinned model files verified.\n'
}
health_worker() {
curl --fail --silent --show-error --retry 5 --retry-connrefused --retry-delay 1 --max-time 3 "http://$HOST:$PORT/v1/models" | grep -q 'SmolVLM2-2.2B-Instruct'
printf 'Self-hosted worker healthy at http://%s:%s/v1\n' "$HOST" "$PORT"
}
start_worker() {
verify_files
if [[ ! -x "$SERVER" ]]; then
printf 'Missing llama-server executable: %s. Run %s install.\n' "$SERVER" "$0" >&2
return 1
fi
mkdir -p "$RUNTIME"
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
health_worker
return
fi
rm -f "$PIDFILE"
nohup "$SERVER" \
--model "$MODEL" \
--mmproj "$MMPROJ" \
--alias SmolVLM2-2.2B-Instruct \
--host "$HOST" \
--port "$PORT" \
--ctx-size 4096 \
--parallel 1 \
--threads "${TIMMY_MODEL_THREADS:-4}" \
--no-webui >"$LOGFILE" 2>&1 &
printf '%s\n' "$!" > "$PIDFILE"
for _ in {1..30}; do
if health_worker >/dev/null 2>&1; then
printf 'Self-hosted worker started privately at http://%s:%s/v1\n' "$HOST" "$PORT"
return
fi
sleep 0.2
done
kill "$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE"
printf 'Worker did not become healthy; inspect %s\n' "$LOGFILE" >&2
return 1
}
stop_worker() {
if [[ ! -f "$PIDFILE" ]]; then
printf 'Self-hosted worker is not running.\n'
return
fi
local pid
pid="$(cat "$PIDFILE")"
kill "$pid" 2>/dev/null || true
for _ in {1..25}; do
if ! kill -0 "$pid" 2>/dev/null; then
rm -f "$PIDFILE"
printf 'Self-hosted worker stopped.\n'
return
fi
sleep 0.1
done
kill -9 "$pid" 2>/dev/null || true
rm -f "$PIDFILE"
printf 'Self-hosted worker stopped.\n'
}
case "${1:-}" in
receipt)
printf 'llama.cpp commit: %s\n' "$LLAMA_CPP_COMMIT"
printf 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf sha256: %s\n' "$MODEL_SHA256"
printf 'mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf sha256: %s\n' "$MMPROJ_SHA256"
printf 'bind address: %s:%s\n' "$HOST" "$PORT"
;;
verify)
verify_files
printf 'Pinned model files verified.\n'
;;
install)
install_worker
;;
start)
start_worker
;;
health)
health_worker
;;
stop)
stop_worker
;;
*)
printf 'Usage: %s {install|start|health|stop|receipt|verify}\n' "$0" >&2
exit 64
;;
esac
+2 -1
View File
@@ -133,8 +133,9 @@ def main() -> int:
contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg"
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree)
run(["npm", "audit", "--audit-level=high"], tree)
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js"):
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"):
run(["node", "--check", file], tree)
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py"], tree)
run(["git", "diff", "--check", commit], tree)
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""Promote and roll back immutable Timmy staging release archives safely."""
from __future__ import annotations
import argparse
from contextlib import contextmanager
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
from typing import Callable, Sequence
import urllib.error
import urllib.request
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
FORBIDDEN_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".gguf", ".bin", ".safetensors", ".onnx", ".pyc")
FORBIDDEN_COMPONENTS = {".env", ".git", ".ssh", "secrets", "credentials", "__pycache__"}
FORBIDDEN_PREFIXES = (("video",), ("artifacts",), ("research", "source-pages"))
class DeploymentError(RuntimeError):
"""A bounded, operator-safe deployment failure."""
@dataclass(frozen=True)
class DeploymentConfig:
root: Path
restart_command: tuple[str, ...]
stop_command: tuple[str, ...]
smoke_command: tuple[str, ...]
health_url: str
command_timeout: float = 180.0
health_timeout: float = 30.0
max_members: int = 10_000
max_member_bytes: int = 50 * 1024 * 1024
max_total_bytes: int = 250 * 1024 * 1024
@property
def releases(self) -> Path:
return self.root / "releases"
@property
def current(self) -> Path:
return self.root / "current"
RunCommand = Callable[..., subprocess.CompletedProcess]
HealthCheck = Callable[[str, str, float], dict]
def _validate_identity(tag: str, commit: str, expected_sha256: str) -> None:
if not TAG_RE.fullmatch(tag):
raise DeploymentError("tag must be 1-80 safe release characters")
if not COMMIT_RE.fullmatch(commit):
raise DeploymentError("commit must be exactly 40 lowercase hexadecimal characters")
if not SHA256_RE.fullmatch(expected_sha256):
raise DeploymentError("SHA-256 must be exactly 64 lowercase hexadecimal characters")
@contextmanager
def _verified_archive_copy(source_path: Path, expected_sha256: str):
"""Copy and hash an untrusted regular archive once, then yield the private copy."""
source_fd = -1
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
try:
source_fd = os.open(source_path, flags)
if not stat.S_ISREG(os.fstat(source_fd).st_mode):
raise DeploymentError("archive source must be a regular file, not a symlink or special file")
with tempfile.TemporaryDirectory(prefix="timmy-verified-archive-") as private_dir:
private_path = Path(private_dir) / "archive"
digest = hashlib.sha256()
with os.fdopen(source_fd, "rb", closefd=True) as source:
source_fd = -1
with private_path.open("xb") as destination:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
destination.write(chunk)
destination.flush()
os.fsync(destination.fileno())
private_path.chmod(0o400)
if digest.hexdigest() != expected_sha256:
raise DeploymentError("archive SHA-256 mismatch; refusing extraction")
yield private_path
except DeploymentError:
raise
except OSError as error:
raise DeploymentError(f"cannot stage archive: {error.strerror or 'I/O error'}") from error
finally:
if source_fd >= 0:
os.close(source_fd)
def _safe_parts(name: str) -> tuple[str, ...]:
if not name or "\\" in name or "\x00" in name or name.startswith("/") or "//" in name:
raise DeploymentError(f"unsafe archive path: {name!r}")
path = PurePosixPath(name)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise DeploymentError(f"unsafe archive path: {name!r}")
return path.parts
def _is_forbidden(parts: tuple[str, ...]) -> bool:
lowered = tuple(part.lower() for part in parts)
basename = lowered[-1]
return (
any(part in FORBIDDEN_COMPONENTS or part.startswith(".env.") for part in lowered)
or basename in {"id_rsa", "id_ed25519", "authorized_keys"}
or basename.endswith(FORBIDDEN_SUFFIXES)
or any(lowered[:len(prefix)] == prefix for prefix in FORBIDDEN_PREFIXES)
)
def inspect_archive(archive_path: Path, config: DeploymentConfig) -> tuple[list[tarfile.TarInfo], str | None]:
"""Validate every tar entry and return members plus a common wrapper directory."""
try:
with tarfile.open(archive_path, mode="r:*") as archive:
members = archive.getmembers()
except (OSError, tarfile.TarError) as error:
raise DeploymentError("archive is not a readable tar file") from error
if not members:
raise DeploymentError("archive is empty")
if len(members) > config.max_members:
raise DeploymentError("archive member count exceeds configured limit")
total = 0
all_parts: list[tuple[str, ...]] = []
for member in members:
parts = _safe_parts(member.name)
all_parts.append(parts)
if not (member.isdir() or member.isreg()):
raise DeploymentError(f"unsupported archive member type: {member.name}")
if member.size < 0 or member.size > config.max_member_bytes:
raise DeploymentError(f"archive member size exceeds configured limit: {member.name}")
total += member.size
if total > config.max_total_bytes:
raise DeploymentError("archive expanded size exceeds configured limit")
common_root = all_parts[0][0] if all(parts[0] == all_parts[0][0] for parts in all_parts) else None
if common_root and _is_forbidden((common_root,)):
raise DeploymentError(f"forbidden archive artifact: {common_root}")
for parts in all_parts:
relative = parts[1:] if common_root else parts
if not relative:
continue
if _is_forbidden(relative):
raise DeploymentError(f"forbidden archive artifact: {'/'.join(relative)}")
return members, common_root
def _extract_validated(archive_path: Path, destination: Path, members: list[tarfile.TarInfo], common_root: str | None) -> None:
with tarfile.open(archive_path, mode="r:*") as archive:
for member in members:
parts = _safe_parts(member.name)
relative = parts[1:] if common_root else parts
if not relative:
continue
target = destination.joinpath(*relative)
if member.isdir():
target.mkdir(parents=True, exist_ok=True, mode=0o755)
continue
target.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
source = archive.extractfile(member)
if source is None:
raise DeploymentError(f"could not read archive member: {member.name}")
with source, target.open("xb") as output:
shutil.copyfileobj(source, output, length=1024 * 1024)
target.chmod(0o755 if member.mode & 0o111 else 0o644)
def _ensure_deployment_root(config: DeploymentConfig, *, create: bool) -> None:
if config.root.is_symlink():
raise DeploymentError("deployment root must not be a symlink")
if config.root.exists() and not config.root.is_dir():
raise DeploymentError("deployment root is not a directory")
if create:
config.root.mkdir(parents=True, exist_ok=True, mode=0o755)
if config.root.is_symlink() or not config.root.is_dir():
raise DeploymentError("deployment root is not a safe directory")
def _ensure_releases_directory(config: DeploymentConfig, *, create: bool) -> None:
_ensure_deployment_root(config, create=create)
if config.releases.is_symlink():
raise DeploymentError("releases directory must not be a symlink")
if config.releases.exists() and not config.releases.is_dir():
raise DeploymentError("releases directory is not a directory")
if create:
config.releases.mkdir(parents=True, exist_ok=True, mode=0o755)
def _current_commit(config: DeploymentConfig) -> str | None:
_ensure_releases_directory(config, create=False)
if not config.current.is_symlink():
if config.current.exists():
raise DeploymentError("current exists but is not a symlink")
return None
target = os.readlink(config.current)
target_path = Path(target)
resolved = (config.current.parent / target_path).resolve() if not target_path.is_absolute() else target_path.resolve()
try:
relative = resolved.relative_to(config.releases.resolve())
except ValueError as error:
raise DeploymentError("current symlink escapes the releases directory") from error
if len(relative.parts) != 1 or not COMMIT_RE.fullmatch(relative.name) or not resolved.is_dir():
raise DeploymentError("current symlink does not name a valid immutable release")
return relative.name
def _atomic_point(config: DeploymentConfig, commit: str | None) -> None:
_ensure_releases_directory(config, create=False)
if config.current.exists() and not config.current.is_symlink():
raise DeploymentError("current exists but is not a symlink")
temporary = config.root / f".current-{os.getpid()}-{secrets.token_hex(6)}"
try:
if commit is None:
config.current.unlink(missing_ok=True)
return
temporary.symlink_to(Path("releases") / commit)
os.replace(temporary, config.current)
finally:
temporary.unlink(missing_ok=True)
def _run_argv(argv: Sequence[str], **options) -> subprocess.CompletedProcess:
if not argv:
raise DeploymentError("configured command must be a non-empty argv array")
timeout = float(options.get("timeout", 180.0))
return subprocess.run(list(argv), check=True, text=True, capture_output=True, shell=False, timeout=timeout)
def poll_health(url: str, expected_commit: str, timeout: float) -> dict:
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise DeploymentError("health check deadline expired")
try:
request = urllib.request.Request(url, headers={"accept": "application/json"})
with urllib.request.urlopen(request, timeout=min(2.0, remaining)) as response:
raw = response.read(65_537)
if len(raw) > 65_536:
raise DeploymentError("health response exceeded 64 KiB")
payload = json.loads(raw)
if response.status == 200 and payload.get("ok") is True and payload.get("commit") == expected_commit:
return payload
except DeploymentError:
raise
except (OSError, urllib.error.URLError, json.JSONDecodeError, AttributeError):
pass
time.sleep(min(0.2, max(0.0, deadline - time.monotonic())))
def _verify(config: DeploymentConfig, commit: str, run_command: RunCommand, health_check: HealthCheck, *, smoke: bool) -> None:
run_command(config.restart_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
health_check(config.health_url, commit, config.health_timeout)
if smoke:
run_command(config.smoke_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
def promote(*, config: DeploymentConfig, tag: str, archive: Path, expected_sha256: str, commit: str,
run_command: RunCommand = _run_argv, health_check: HealthCheck = poll_health) -> dict:
archive = Path(archive)
_validate_identity(tag, commit, expected_sha256)
_ensure_deployment_root(config, create=False)
with _verified_archive_copy(archive, expected_sha256) as verified_archive:
members, common_root = inspect_archive(verified_archive, config)
final = config.releases / commit
if final.exists() or final.is_symlink():
raise DeploymentError(f"immutable release already exists: {commit}")
previous = _current_commit(config)
_ensure_releases_directory(config, create=True)
pending = config.releases / f".pending-{commit}-{secrets.token_hex(6)}"
if pending.exists() or pending.is_symlink():
raise DeploymentError("pending release boundary already exists")
pending.mkdir(mode=0o755)
if pending.is_symlink() or not pending.is_dir():
raise DeploymentError("pending release boundary is not a safe directory")
try:
_extract_validated(verified_archive, pending, members, common_root)
metadata = {"schemaVersion": 1, "tag": tag, "commit": commit, "sha256": expected_sha256}
(pending / ".timmy-release.json").write_text(json.dumps(metadata, sort_keys=True) + "\n", encoding="utf-8")
os.replace(pending, final)
except Exception:
shutil.rmtree(pending, ignore_errors=True)
raise
_atomic_point(config, commit)
try:
_verify(config, commit, run_command, health_check, smoke=True)
except Exception as error:
_atomic_point(config, previous)
try:
if previous is not None:
_verify(config, previous, run_command, health_check, smoke=False)
else:
run_command(config.stop_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
except Exception as rollback_error:
raise DeploymentError("promotion verification failed and rollback verification also failed") from rollback_error
if previous is None:
raise DeploymentError("promotion verification failed; no prior release; service stopped") from error
raise DeploymentError("promotion verification failed; prior release restored") from error
return {"ok": True, "action": "promote", **metadata, "previousCommit": previous}
def _validate_release_directory(config: DeploymentConfig, commit: str) -> Path:
release = config.releases / commit
if not release.is_dir() or release.is_symlink():
raise DeploymentError(f"release does not exist: {commit}")
metadata_path = release / ".timmy-release.json"
entrypoint = release / "server.mjs"
if metadata_path.is_symlink() or entrypoint.is_symlink() or not entrypoint.is_file():
raise DeploymentError("release metadata or entrypoint is invalid")
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise DeploymentError("release metadata is missing or invalid") from error
if metadata.get("commit") != commit or not TAG_RE.fullmatch(str(metadata.get("tag", ""))):
raise DeploymentError("release metadata does not match requested commit")
return release
def rollback(*, config: DeploymentConfig, commit: str, run_command: RunCommand = _run_argv,
health_check: HealthCheck = poll_health) -> dict:
if not COMMIT_RE.fullmatch(commit):
raise DeploymentError("commit must be exactly 40 lowercase hexadecimal characters")
_validate_release_directory(config, commit)
previous = _current_commit(config)
if previous == commit:
raise DeploymentError("requested release is already current")
_atomic_point(config, commit)
try:
_verify(config, commit, run_command, health_check, smoke=False)
except Exception as error:
_atomic_point(config, previous)
if previous is not None:
try:
_verify(config, previous, run_command, health_check, smoke=False)
except Exception as rollback_error:
raise DeploymentError("rollback failed and prior release could not be restored") from rollback_error
raise DeploymentError("rollback verification failed; prior release restored") from error
return {"ok": True, "action": "rollback", "commit": commit, "previousCommit": previous}
def status(config: DeploymentConfig) -> dict:
commit = _current_commit(config)
if commit is None:
return {"ok": True, "active": False, "commit": None, "release": None}
metadata_path = config.releases / commit / ".timmy-release.json"
metadata = {}
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {"ok": True, "active": True, "commit": commit, "release": metadata.get("tag"), "sha256": metadata.get("sha256")}
def _json_argv(value: str, option: str) -> tuple[str, ...]:
try:
parsed = json.loads(value)
except json.JSONDecodeError as error:
raise DeploymentError(f"{option} must be a JSON argv array") from error
if not isinstance(parsed, list) or not parsed or not all(isinstance(item, str) and item for item in parsed):
raise DeploymentError(f"{option} must be a non-empty JSON argv array of strings")
return tuple(parsed)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(os.environ.get("TIMMY_STAGING_ROOT", "/opt/timmy-staging")))
parser.add_argument("--dry-run", action="store_true", help="validate and print the intended action without mutation or commands")
parser.add_argument("--restart-command", default=os.environ.get("TIMMY_STAGING_RESTART_COMMAND", '["systemctl","restart","timmy-staging.service"]'))
parser.add_argument("--stop-command", default=os.environ.get("TIMMY_STAGING_STOP_COMMAND", '["systemctl","stop","timmy-staging.service"]'))
parser.add_argument("--smoke-command", default=os.environ.get("TIMMY_STAGING_SMOKE_COMMAND", '["npm","run","test:staging-smoke"]'))
parser.add_argument("--health-url", default=os.environ.get("TIMMY_STAGING_HEALTH_URL", "http://127.0.0.1:4174/timmy-staging/api/healthz"))
subparsers = parser.add_subparsers(dest="command", required=True)
promote_parser = subparsers.add_parser("promote")
promote_parser.add_argument("--tag", required=True)
promote_parser.add_argument("--archive", required=True, type=Path)
promote_parser.add_argument("--sha256", required=True)
promote_parser.add_argument("--commit", required=True)
rollback_parser = subparsers.add_parser("rollback")
rollback_parser.add_argument("--commit", required=True)
subparsers.add_parser("status")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
config = DeploymentConfig(
root=args.root,
restart_command=_json_argv(args.restart_command, "restart command"),
stop_command=_json_argv(args.stop_command, "stop command"),
smoke_command=_json_argv(args.smoke_command, "smoke command"),
health_url=args.health_url,
)
if args.command == "status":
result = status(config)
elif args.command == "promote":
if args.dry_run:
_validate_identity(args.tag, args.commit, args.sha256)
_ensure_deployment_root(config, create=False)
with _verified_archive_copy(args.archive, args.sha256) as verified_archive:
members, wrapper = inspect_archive(verified_archive, config)
result = {"ok": True, "dryRun": True, "action": "promote", "commit": args.commit, "tag": args.tag, "members": len(members), "wrapper": wrapper}
else:
result = promote(config=config, tag=args.tag, archive=args.archive, expected_sha256=args.sha256, commit=args.commit)
else:
if args.dry_run:
if not COMMIT_RE.fullmatch(args.commit):
raise DeploymentError("rollback release does not exist or commit is invalid")
_validate_release_directory(config, args.commit)
result = {"ok": True, "dryRun": True, "action": "rollback", "commit": args.commit}
else:
result = rollback(config=config, commit=args.commit)
print(json.dumps(result, sort_keys=True))
return 0
except DeploymentError as error:
print(f"deploy_staging: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -105,7 +105,7 @@ await caption('Automated checks replay this synthetic path before review', 1200)
await caption('One clear photo action. Manual logging stays one tap away.', 1500);
await tap('[data-scan]', 450);
await page.getByText(/Self-hosted model ready/i).waitFor();
await caption('The self-hosted vision route is ready', 1000);
await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('Nothing uploads until explicit consent', 1100);
await page.locator('#ai-consent').check();
+69 -11
View File
@@ -1,4 +1,5 @@
import http from 'node:http';
import { isIP } from 'node:net';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,46 +9,103 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig }
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
const host=process.env.HOST||'0.0.0.0';
if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
function resolveBasePath(value) {
const raw=String(value||'');
if(!raw||raw==='/')return '';
if(raw.length>128||!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*\/?$/.test(raw))throw new Error('TIMMY_BASE_PATH must be a canonical absolute URL path using plain unreserved characters.');
const normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
if(normalized.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
return normalized;
}
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'};
const visionConfig=resolveVisionConfig(process.env);
const agentConfig=resolveHermesAgentConfig(process.env);
const agentService=createHermesAgentService({config:agentConfig});
const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development';
const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000';
const publicBasePath=basePath||'/';
const appRoot=basePath?`${basePath}/`:'/';
const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:'';
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[character]));}
function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${secure}`}
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
// Graceful shutdown: SIGTERM must cancel in-flight Hermes turns (killing their
// child processes) instead of abandoning them as orphan subprocesses.
const inFlightChats = new Set();
let shuttingDown = false;
function trackChat(res, controller) {
if (shuttingDown) controller.abort(new Error('Timmy is shutting down. Your journal still works — try again shortly.'));
const entry = { res, controller };
inFlightChats.add(entry);
// A vanished client must release its queue slot and stop any in-flight
// Hermes turn instead of consuming inference capacity silently.
res.on('close', () => {
inFlightChats.delete(entry);
if (!res.writableEnded) {
try { controller.abort(new Error('The browser closed the request. Your journal still works.')); } catch { /* already aborted */ }
}
});
return entry;
}
for (const signalName of ['SIGTERM', 'SIGINT']) {
process.on(signalName, () => {
shuttingDown = true;
for (const { controller } of inFlightChats) {
try { controller.abort(new Error('Server is shutting down.')); } catch { /* already aborted */ }
}
process.exit(0);
});
}
http.createServer(async(req,res)=>{
try{
const url=new URL(req.url,'http://localhost');
if(url.pathname==='/api/vision-status'&&req.method==='GET'){
if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')}
if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()}
const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname;
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
if(appPath==='/api/vision-status'&&req.method==='GET'){
const provider=await probeVisionProvider(visionConfig);
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
}
if(url.pathname==='/api/analyze'&&req.method==='POST'){
if(appPath==='/api/analyze'&&req.method==='POST'){
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
const payload=await readJson(req);
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})}
}
if(url.pathname==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(url.pathname==='/api/agent/unlock'&&req.method==='POST'){
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
}
if(url.pathname==='/api/agent/chat'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
if(appPath==='/api/agent/chat'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);const chatAbort=new AbortController();trackChat(res,chatAbort);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload,signal:chatAbort.signal}))}catch(error){return sendAgentError(res,error)}
}
if(url.pathname.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()}
const pathname=decodeURIComponent(url.pathname);
const pathname=decodeURIComponent(appPath);
let path=normalize(join(root,pathname==='/'?'index.html':pathname));
if(!path.startsWith(root))throw new Error('bad path');
const info=await stat(path);if(info.isDirectory())path=join(path,'index.html');
const body=await readFile(path);res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
let body=await readFile(path);
if(appPath==='/'||appPath==='/index.html'){
const configMeta=`<meta name="timmy-base-path" content="${escapeHtmlAttribute(publicBasePath)}">\n <meta name="timmy-staging-label" content="${escapeHtmlAttribute(stagingLabel)}">`;
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">\n ${configMeta}`);
}
if(appPath==='/manifest.webmanifest'){
const manifest=JSON.parse(body.toString('utf8'));manifest.start_url=appRoot;manifest.scope=appRoot;manifest.icons=manifest.icons.map(icon=>({...icon,src:`${appRoot}${icon.src.replace(/^\//,'')}`}));body=JSON.stringify(manifest);
}
res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
}catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
}).listen(port,'0.0.0.0',()=>console.log(`Timmy is listening on http://0.0.0.0:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
+50 -5
View File
@@ -1,5 +1,50 @@
const CACHE='timmy-shell-v4';
const ASSETS=['/','/index.html','/styles.css','/app.js','/src/domain.js','/src/analysis.js','/manifest.webmanifest','/assets/timmy.svg','/assets/icon-192.svg','/assets/icon-512.svg'];
self.addEventListener('install',event=>event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(ASSETS)).then(()=>self.skipWaiting())));
self.addEventListener('activate',event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
self.addEventListener('fetch',event=>{if(event.request.method!=='GET')return;event.respondWith(fetch(event.request).then(response=>{const copy=response.clone();caches.open(CACHE).then(cache=>cache.put(event.request,copy));return response}).catch(()=>caches.match(event.request).then(hit=>hit||caches.match('/index.html'))))});
const ROOT = new URL(self.registration.scope).pathname;
const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`;
const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`;
const CACHE = `${CACHE_NAMESPACE}v5`;
const ASSETS = [
'',
'index.html',
'styles.css',
'app.js',
'src/domain.js',
'src/analysis.js',
'manifest.webmanifest',
'assets/timmy.svg',
'assets/icon-192.svg',
'assets/icon-512.svg',
].map(appPath);
self.addEventListener('install', event => event.waitUntil(
caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting()),
));
self.addEventListener('activate', event => event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(key =>
(key.startsWith(CACHE_NAMESPACE) && key !== CACHE)
|| (ROOT === '/' && key === 'timmy-shell-v4')
).map(key => caches.delete(key))))
.then(() => self.clients.claim()),
));
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const pathname = new URL(event.request.url).pathname;
if (!pathname.startsWith(ROOT)) return;
if (pathname.startsWith(appPath('api/'))) {
event.respondWith(fetch(event.request));
return;
}
event.respondWith(
fetch(event.request)
.then(response => {
if (!/\bno-store\b/i.test(response.headers.get('cache-control') || '')) {
const copy = response.clone();
caches.open(CACHE).then(cache => cache.put(event.request, copy));
}
return response;
})
.catch(() => caches.open(CACHE).then(async cache =>
(await cache.match(event.request)) || cache.match(appPath('index.html'))
)),
);
});
+66 -15
View File
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { isAbsolute } from 'node:path';
import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js';
import { OverloadError, createInferenceQueue } from './inference-queue.js';
const MAX_MESSAGE_CHARS = 4000;
const MAX_LEDGER_ENTRIES = 20;
@@ -44,6 +45,8 @@ export function resolveHermesAgentConfig(env = process.env) {
maxRequestsPerMinute: positiveInt(env.TIMMY_AGENT_RATE_PER_MINUTE, 12, 1, 60),
maxUnlockAttemptsPerMinute: positiveInt(env.TIMMY_AGENT_UNLOCK_RATE_PER_MINUTE, 5, 1, 30),
maxSessions: positiveInt(env.TIMMY_AGENT_MAX_SESSIONS, 64, 1, 512),
maxConcurrentTurns: positiveInt(env.TIMMY_AGENT_MAX_CONCURRENT_TURNS, 1, 1, 8),
maxQueueDepth: positiveInt(env.TIMMY_AGENT_MAX_QUEUE_DEPTH, 4, 0, 64),
publicStatus(authenticated = false) {
return {
enabled,
@@ -86,12 +89,30 @@ export function parseHermesCliOutput(output) {
return { sessionId: marker[1], reply };
}
function execFilePromise(command, args, options) {
function execFilePromise(command, args, options, signal) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
// Abort must actually terminate the spawned CLI, not merely abandon the
// promise — otherwise every cancelled turn leaks an orphan subprocess.
let killedByAbort = false;
const child = execFile(command, args, options, (error, stdout, stderr) => {
if (killedByAbort && signal) {
reject(normalizeAbortReason(signal.reason));
return;
}
if (error) reject(error);
else resolve({ stdout, stderr });
});
if (!signal) return;
const killTree = () => {
killedByAbort = true;
try { child.kill('SIGTERM'); } catch { /* already exited */ }
const forceKill = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* already exited */ }
}, 2_000);
forceKill.unref?.();
};
if (signal.aborted) killTree();
else signal.addEventListener('abort', killTree, { once: true });
});
}
@@ -103,7 +124,8 @@ export function buildHermesEnvironment(source = process.env) {
return env;
}
export async function runHermesCliTurn({ prompt, hermesSessionId, config, execImpl = execFilePromise }) {
export async function runHermesCliTurn({ prompt, hermesSessionId, config, signal, execImpl = execFilePromise }) {
if (signal?.aborted) throw normalizeAbortReason(signal.reason);
const args = ['chat', '-q', prompt, '-Q', '--source', 'tool', '--max-turns', String(config.maxTurns), '--in', config.workdir];
if (hermesSessionId) args.push('--resume', hermesSessionId, '--no-restore-cwd');
const output = await execImpl(config.command, args, {
@@ -112,10 +134,15 @@ export async function runHermesCliTurn({ prompt, hermesSessionId, config, execIm
maxBuffer: 1024 * 1024,
windowsHide: true,
env: buildHermesEnvironment(),
});
}, signal);
return parseHermesCliOutput(`${output.stderr || ''}\n${output.stdout || ''}`);
}
function normalizeAbortReason(reason) {
if (reason instanceof Error) return reason;
return new Error(typeof reason === 'string' && reason.trim() ? reason : 'Request cancelled.');
}
function sanitizeLedger(entries) {
if (!Array.isArray(entries)) throw new AgentGatewayError(400, 'Ledger context must be an array.');
return entries.slice(-MAX_LEDGER_ENTRIES).map(entry => ({
@@ -139,6 +166,7 @@ export function createHermesAgentService({
runTurn = input => runHermesCliTurn(input),
randomToken = () => randomBytes(32).toString('base64url'),
now = () => Date.now(),
queue = createInferenceQueue({ concurrency: config.maxConcurrentTurns, maxQueueDepth: config.maxQueueDepth, requestTimeoutMs: config.timeoutMs, now }),
} = {}) {
const sessions = new Map();
let unlockFailures = [];
@@ -182,7 +210,7 @@ export function createHermesAgentService({
return { cookieToken, public: config.publicStatus(true) };
},
async chat({ origin, cookieToken, payload }) {
async chat({ origin, cookieToken, payload, signal }) {
requireOrigin(origin);
const session = lookup(cookieToken);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new AgentGatewayError(400, 'Invalid chat request.');
@@ -195,25 +223,48 @@ export function createHermesAgentService({
if (urgent) return { reply: urgentChatMessage, connected: true, safetyOverride: true };
const cutoff = now() - RATE_WINDOW_MS;
session.requests = session.requests.filter(time => time > cutoff);
if (session.requests.length >= config.maxRequestsPerMinute || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.');
if (session.requests.length >= config.maxRequestsPerMinute) throw new AgentGatewayError(429, 'Too many messages in a row. Try again shortly.');
session.requests.push(now());
session.busy = true;
if (signal?.aborted) throw new AgentGatewayError(503, 'The browser closed the request before Timmy could think. Your journal still works.');
// Forward browser-side aborts into the inference queue by handle so a
// disconnected client releases its slot immediately.
let handle = null;
const forwardAbort = () => {
if (!handle) return;
const reason = signal.reason instanceof Error ? signal.reason : normalizeReason(signal.reason);
queue.cancel(handle, reason);
};
if (signal) signal.addEventListener('abort', forwardAbort, { once: true });
try {
const result = await runTurn({
prompt: buildPrompt(message, ledger, !session.hermesSessionId),
hermesSessionId: session.hermesSessionId,
config,
});
const result = await (handle = queue.run(async runSignal => {
if (runSignal.aborted) throw normalizeReason(runSignal.reason);
// Session continuity is read at execution time so a request that
// waited in the queue resumes the conversation state left by the
// turn that ran before it.
return runTurn({
prompt: buildPrompt(message, ledger, !session.hermesSessionId),
hermesSessionId: session.hermesSessionId,
config,
signal: runSignal,
});
}));
const reply = stripUnsafeControls(result?.reply || '');
if (!reply || reply.length > 12_000 || !/^[A-Za-z0-9_-]{8,128}$/.test(String(result?.sessionId || ''))) throw new Error('invalid agent result');
session.hermesSessionId = result.sessionId;
if (!session.hermesSessionId) session.hermesSessionId = result.sessionId;
return { reply, connected: true };
} catch (error) {
if (error instanceof AgentGatewayError) throw error;
const messageText = String(error?.message || '');
if (error instanceof OverloadError || /timed out|busy right now|disconnected|closed before/i.test(messageText)) {
throw new AgentGatewayError(503, messageText);
}
throw new AgentGatewayError(503, 'Hermes is temporarily unavailable. Your local journal still works.');
} finally {
session.busy = false;
}
},
};
}
function normalizeReason(reason) {
if (reason instanceof Error) return reason;
return new Error(typeof reason === 'string' && reason.trim() ? reason : 'Request cancelled.');
}
+130
View File
@@ -0,0 +1,130 @@
export class OverloadError extends Error {
constructor(message = 'Timmy is busy right now. Your journal still works — try again shortly or continue manually.') {
super(message);
this.name = 'OverloadError';
}
}
function normalizeReason(reason) {
if (reason instanceof Error) return reason;
const text = String(reason ?? '').trim().slice(0, 200);
return new Error(text || 'Request cancelled.');
}
function expiredMessage() {
return 'Timmy is busy right now and your request timed out waiting. Your journal still works — try again shortly or continue manually.';
}
export function createInferenceQueue({
concurrency = 1,
maxQueueDepth = 0,
requestTimeoutMs = 60_000,
now = () => Date.now(),
} = {}) {
let active = 0;
const waiting = [];
const handles = new Map();
function expireStale(nowMs) {
while (waiting.length) {
if (nowMs - waiting[0].enqueuedAt <= requestTimeoutMs) break;
const expired = waiting.shift();
expired.settled = true;
expired.abort(new OverloadError(expiredMessage()));
}
}
function pump(nowMs = now()) {
expireStale(nowMs);
while (active < concurrency && waiting.length) {
const entry = waiting.shift();
if (entry.settled) continue;
active += 1;
entry.grant();
}
}
function release() {
active -= 1;
pump();
}
async function admit(record) {
const { controller } = record;
if (controller.signal.aborted) throw normalizeReason(controller.signal.reason);
expireStale(now());
if (active < concurrency) {
active += 1;
return;
}
if (waiting.length >= maxQueueDepth) throw new OverloadError();
await new Promise((resolve, reject) => {
const entry = {
enqueuedAt: now(),
settled: false,
grant: resolve,
abort: reject,
};
record.entry = entry;
waiting.push(entry);
});
record.entry = null;
}
function run(task) {
const controller = new AbortController();
const record = { controller, entry: null };
let resolveOutcome;
let rejectOutcome;
// Deliberately not an async function: the caller must receive the very
// promise registered in `handles`, or cancellation lookups would target
// a different object than the one they hold.
const promise = new Promise((resolve, reject) => {
resolveOutcome = resolve;
rejectOutcome = reject;
});
handles.set(promise, record);
(async () => {
let holdsSlot = false;
try {
await admit(record);
holdsSlot = true;
// The task body may not have started even though a slot is held; an
// external cancel that raced ahead must still stop it here.
if (controller.signal.aborted) throw normalizeReason(controller.signal.reason);
resolveOutcome(await task(controller.signal));
} catch (error) {
rejectOutcome(error instanceof Error ? error : normalizeReason(error));
} finally {
if (holdsSlot) release();
}
// Drop the handle one microtask after the caller-visible promise
// settles, so a same-tick external cancel still finds it.
promise.then(
() => handles.delete(promise),
() => handles.delete(promise),
);
})();
promise.catch(() => {});
return promise;
}
function cancel(handle, reason) {
const record = handles.get(handle);
if (!record) return false;
handles.delete(handle);
const normalized = normalizeReason(reason);
const entry = record.entry;
if (entry && !entry.settled) {
entry.settled = true;
const index = waiting.indexOf(entry);
if (index >= 0) waiting.splice(index, 1);
entry.abort(normalized);
}
record.controller.abort(normalized);
return true;
}
pump();
return { run, cancel };
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function resolveVisionConfig(env = process.env) {
if (!PROFILES[profile]) throw new Error('TIMMY_VISION_PROFILE must be hosted or selfhost.');
const defaults = PROFILES[profile];
const config = {
enabled: env.TIMMY_VISION_ENABLED !== '0',
enabled: !['0', 'false'].includes(String(env.TIMMY_VISION_ENABLED || '').toLowerCase()),
profile,
processor: defaults.processor,
baseUrl: env.TIMMY_VISION_BASE_URL || defaults.baseUrl,
+7 -3
View File
@@ -7,18 +7,22 @@ function providerEndpoint(baseUrl) {
return `${url.toString().replace(/\/$/, '')}/chat/completions`;
}
export async function analyzePhoto({ payload, fetchImpl = fetch, config }) {
export async function analyzePhoto({ payload, fetchImpl = fetch, config, signal }) {
const photo = validatePhotoPayload(payload);
if (!config?.model) throw new Error('AI analysis is not configured.');
const endpoint = providerEndpoint(config.baseUrl);
// The provider call honours both its own deadline and a per-request
// cancellation so abandoned clients stop consuming inference capacity.
const timeoutSignal = AbortSignal.timeout(config.requestTimeoutMs || 60_000);
const combinedSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
const response = await fetchImpl(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${config.apiKey || 'local-proxy'}`,
authorization: 'Bearer ' + String(config.apiKey || 'local-proxy'),
},
body: JSON.stringify(buildVisionRequest({ imageDataUrl: photo.imageDataUrl, model: config.model })),
signal: AbortSignal.timeout(config.requestTimeoutMs || 60_000),
signal: combinedSignal,
}).catch(() => { throw new Error('AI analysis is temporarily unavailable. Continue manually.'); });
if (!response.ok) throw new Error('AI analysis is temporarily unavailable. Continue manually.');
let data;
+2 -1
View File
@@ -16,8 +16,9 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
.bottom-nav{position:fixed;left:50%;bottom:calc(10px + env(safe-area-inset-bottom));transform:translateX(-50%);width:min(calc(100% - 24px),640px);height:68px;padding:5px;display:grid;grid-template-columns:repeat(3,1fr);background:rgba(255,253,249,.88);border:1px solid rgba(70,54,43,.12);border-radius:23px;box-shadow:0 16px 38px rgba(50,37,29,.18);backdrop-filter:blur(20px);z-index:20}.nav-btn{border:0;background:transparent;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;color:var(--muted);font-size:11px;font-weight:700;cursor:pointer}.nav-btn b{font-size:19px;line-height:1}.nav-btn.active{background:var(--ink);color:#fff;box-shadow:0 6px 14px rgba(33,29,26,.15)}
.btn{min-height:50px;border:0;border-radius:16px;padding:0 17px;font-weight:800;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px}.btn:active{transform:scale(.98)}.btn:disabled{opacity:.45;cursor:not-allowed}.btn-primary{background:var(--ink);color:#fff}.btn-secondary{background:var(--teal-soft);color:var(--teal)}.btn-ghost{background:transparent;border:1px solid var(--line)}.btn-danger{background:var(--red);color:#fff}.btn-wide{width:100%}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.fine{font-size:12px;color:var(--muted);line-height:1.45}.empty{text-align:center;padding:22px 10px;color:var(--muted)}.empty img{width:68px}.empty h3{color:var(--ink);margin-top:8px}.empty p{margin-bottom:0}
.chat-page{display:flex;flex-direction:column;min-height:calc(100vh - 175px)}.chat-title{padding-bottom:8px}.agent-status{display:flex;align-items:center;gap:11px;padding:11px 13px;background:rgba(255,253,250,.7);border:1px solid var(--line);border-radius:17px;margin-bottom:12px}.agent-status>i{width:10px;height:10px;border-radius:50%;background:#a9a39d;box-shadow:0 0 0 5px rgba(169,163,157,.13)}.agent-status.connected>i{background:#209479;box-shadow:0 0 0 5px rgba(32,148,121,.13)}.agent-status.locked>i{background:#d19b27}.agent-status strong,.agent-status small{display:block}.agent-status strong{font-size:13px}.agent-status small{font-size:11px;color:var(--muted);margin-top:2px}.conversation{background:var(--surface);border:1px solid var(--line);border-radius:23px;padding:13px;box-shadow:0 10px 30px rgba(55,40,31,.05)}.chat{display:flex;flex-direction:column;gap:9px;min-height:195px;max-height:42vh;overflow:auto;padding:4px 1px 14px}.bubble{max-width:86%;padding:11px 13px;border-radius:17px;line-height:1.42;font-size:14px;white-space:pre-wrap}.bubble.timmy{align-self:flex-start;background:#efebe4;border-bottom-left-radius:5px}.bubble.user{align-self:flex-end;background:var(--teal);color:white;border-bottom-right-radius:5px}.thinking{display:flex;gap:4px}.thinking span{width:6px;height:6px;border-radius:50%;background:#8b847d;animation:blink 1s infinite}.thinking span:nth-child(2){animation-delay:.15s}.thinking span:nth-child(3){animation-delay:.3s}@keyframes blink{50%{opacity:.25;transform:translateY(-2px)}}.composer{display:grid;grid-template-columns:1fr 45px;gap:8px;align-items:end;background:#f0ece5;border-radius:18px;padding:6px}.composer textarea{border:0;background:transparent;resize:none;min-height:42px;max-height:110px;padding:10px 9px;outline:0;color:var(--ink)}.composer button{width:44px;height:44px;border:0;border-radius:14px;background:var(--ink);color:white;font-size:22px;cursor:pointer}.composer button:disabled{opacity:.4}.composer-note{font-size:10px;color:var(--muted);margin:7px 5px 0;line-height:1.35}.chat-error{font-size:12px;color:var(--red);margin:0 4px 8px}.safety-line{margin:13px 4px 0;color:var(--muted);font-size:11px;line-height:1.45}.safety-line strong{color:var(--ink)}.unlock-card{background:#fff8e6;border:1px solid #ead9a9;border-radius:20px;padding:14px;margin-bottom:12px}.unlock-card>label{display:block;font-size:12px;font-weight:800;margin-bottom:7px}.unlock-row{display:grid;grid-template-columns:1fr auto;gap:8px}.unlock-card .fine{margin:8px 2px 0}
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}.progress-step-1{width:33.34%}.progress-step-2{width:66.68%}.progress-step-3{width:100%}
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:22px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
.privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)}
.staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72}
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js';
const origin = 'http://127.0.0.1:4173';
const accessCode = 'test-agent-access-code-2026';
const configured = () => resolveHermesAgentConfig({
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
TIMMY_PUBLIC_ORIGIN: origin,
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
});
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
async function unlock(service, token) {
await service.unlock({ origin, accessCode });
return token;
}
test('chat requests beyond the queue depth get a stable sanitized overload fallback', async () => {
let clock = 0;
const gate = deferred();
let started = 0;
const service = createHermesAgentService({
config: { ...configured(), maxQueueDepth: 1 },
randomToken: () => 'queue-cookie',
now: () => clock,
runTurn: async () => {
started += 1;
return gate.promise;
},
});
await unlock(service);
const first = service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'first', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(started, 1);
const second = service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'second', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(started, 1, 'second request waits in the bounded queue');
await assert.rejects(() => service.chat({ origin, cookieToken: 'queue-cookie', payload: { message: 'third', ledger: [] } }), error => {
assert.equal(error.status, 503);
assert.match(error.message, /busy|try again/i);
assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir/i);
return true;
}, 'request beyond max queue depth fails fast with sanitized copy');
gate.resolve({ reply: 'eventually', sessionId: 'private-session' });
assert.match((await first).reply, /eventually/);
assert.match((await second).reply, /eventually/);
});
test('a chat request abandoned by its browser is cancelled without running Hermes and frees its slot', async () => {
let clock = 0;
const gate = deferred();
const started = [];
const service = createHermesAgentService({
config: { ...configured(), maxQueueDepth: 2 },
randomToken: () => 'disconnect-cookie',
now: () => clock,
runTurn: async ({ signal }) => {
started.push('run');
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')));
gate.promise.then(resolve, reject);
});
},
});
await unlock(service, 'disconnect-cookie');
const active = service.chat({ origin, cookieToken: 'disconnect-cookie', payload: { message: 'holding slot', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
const abandonSignal = new AbortController();
const abandoned = service.chat({ origin, cookieToken: 'disconnect-cookie', payload: { message: 'queued', ledger: [] }, signal: abandonSignal.signal });
await new Promise(resolve => setImmediate(resolve));
abandonSignal.abort(new Error('client disconnected'));
await assert.rejects(() => abandoned, error => error.status === 503 && /closed|disconnect/i.test(error.message));
gate.resolve({ reply: 'late answer', sessionId: 'private-session' });
assert.match((await active).reply, /late answer/);
assert.deepEqual(started, ['run'], 'the cancelled request never invoked a Hermes turn');
});
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { constants as fsConstants } from 'node:fs';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig, runHermesCliTurn } from '../src/hermes-agent-service.js';
const origin = 'http://127.0.0.1:4173';
const accessCode = 'test-agent-access-code-2026';
const configured = () => resolveHermesAgentConfig({
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
TIMMY_PUBLIC_ORIGIN: origin,
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
});
async function waitUntil(check, timeoutMs = 3_000, stepMs = 20) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return true;
await new Promise(resolve => setTimeout(resolve, stepMs));
}
return false;
}
function isAlive(pid) {
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
}
test('aborting an in-flight Hermes turn kills the child promptly and leaves no orphan process', async () => {
const dir = await mkdir(join(tmpdir(), `timmy-orphan-${Date.now()}-`), { recursive: true });
const pidFile = join(dir, 'child.pid');
const fixture = join(dir, 'slow-hermes.sh');
// Prints valid Hermes output immediately, then idles long enough that only
// an explicit kill can end it. `exec` makes the recorded PID the sleeper.
await writeFile(fixture, `#!/usr/bin/env bash\nprintf '%s\\n' $$ > '${pidFile}'\necho "session_id: fixture_kill_001"\necho "thinking"\nexec sleep 60\n`, { mode: fsConstants.S_IRWXU });
const controller = new AbortController();
const turn = runHermesCliTurn({
prompt: 'hello',
hermesSessionId: null,
config: { ...configured(), command: fixture, workdir: dir },
signal: controller.signal,
env: { TMPDIR: dir },
});
const pidAppeared = await waitUntil(async () => {
try { Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8')); return true; } catch { return false; }
}, 4_000);
assert.ok(pidAppeared, 'fixture child started');
const pid = Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8'));
assert.ok(isAlive(pid), 'child is running before cancellation');
const startedAt = Date.now();
controller.abort(new Error('client disconnected'));
await assert.rejects(() => Promise.race([
turn,
new Promise((_, reject) => setTimeout(() => reject(new Error('turn was not cancelled')), 5_000)),
]), error => /disconnect|abort|killed|terminated/i.test(String(error?.message || error?.code || '')));
const cancelMs = Date.now() - startedAt;
assert.ok(cancelMs < 4_000, `cancellation returned promptly (took ${cancelMs}ms)`);
const reaped = await waitUntil(() => !isAlive(pid), 4_000);
assert.ok(reaped, 'child process was actually killed — no orphan subprocess remains');
await rm(dir, { recursive: true, force: true });
}, { timeout: 20_000 });
test('a queued chat past the queue deadline is cleaned up with a sanitized timeout response and zero extra Hermes calls', async () => {
let clock = 0;
let started = 0;
let releaseActive;
const service = createHermesAgentService({
config: { ...configured(), timeoutMs: 50 },
randomToken: () => 'deadline-cookie',
now: () => clock,
runTurn: async () => {
started += 1;
return new Promise(resolve => { releaseActive = resolve; });
},
});
await service.unlock({ origin, accessCode });
const active = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'wedge', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(started, 1);
const queued = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'waiting', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(started, 1);
clock += 200; // past the 50ms queue deadline
// Attach the rejection expectation before the sweep runs so the expired
// request never counts as an unhandled rejection.
const expiryExpectation = assert.rejects(() => queued, error => {
assert.ok(error instanceof AgentGatewayError);
assert.equal(error.status, 503);
assert.match(error.message, /timed out|busy/i);
assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir|auth/i);
return true;
});
service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'sweep trigger', ledger: [] } }).catch(() => {});
await expiryExpectation;
assert.equal(started, 1, 'expired request never reached Hermes');
releaseActive({ reply: 'late but valid', sessionId: 'deadline_session_01' });
assert.match((await active).reply, /late but valid/);
});
+20
View File
@@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
const workflowPath = new URL('../.gitea/workflows/quality.yml', import.meta.url);
const diffCheckPath = new URL('../scripts/check_diff.sh', import.meta.url);
const pythonRequirementsPath = new URL('../requirements-test.txt', import.meta.url);
const packagePath = new URL('../package.json', import.meta.url);
test('Gitea CI gates pull requests and main with the reproducible quality suite', async () => {
const workflow = await readFile(workflowPath, 'utf8');
@@ -31,6 +32,25 @@ test('CI pins the Python image dependency required by the full unit suite', asyn
assert.match(requirements, /^Pillow==12\.3\.0$/m);
});
test('default unit suite includes the self-host bootstrap contract', async () => {
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
assert.match(packageJson.scripts.test, /tests\/selfhost-bootstrap\.test\.js/);
});
test('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => {
const [packageJson, workflow] = await Promise.all([
readFile(packagePath, 'utf8').then(JSON.parse),
readFile(workflowPath, 'utf8'),
]);
assert.match(workflow, /python3 tests\/staging-deploy\.test\.py -v/);
assert.match(packageJson.scripts.test, /tests\/staging-config\.test\.js/);
assert.equal(packageJson.scripts['test:staging-smoke'], 'node tests/staging.acceptance.mjs');
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/staging\.acceptance\.mjs/);
assert.match(workflow, /node --check tests\/staging\.acceptance\.mjs/);
assert.doesNotMatch(workflow, /test:staging-smoke|TIMMY_STAGING_URL/);
});
test('diff hygiene checks only the pull request or latest commit range', async () => {
const script = await readFile(diffCheckPath, 'utf8');
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// Slow Hermes fixture for bounded-queue acceptance tests. Normal prompts
// answer immediately; prompts containing HOLD block until a release file
// appears inside the agent workdir (the value after `--in`), so tests can
// saturate the queue deterministically without touching shared /tmp state.
import { existsSync } from 'node:fs';
import { appendFileSync } from 'node:fs';
const args = process.argv.slice(2);
const promptIndex = args.indexOf('-q');
const prompt = promptIndex >= 0 ? args[promptIndex + 1] : '';
const workdirIndex = args.indexOf('--in');
const workdir = workdirIndex >= 0 ? args[workdirIndex + 1] : '/tmp';
appendFileSync(`${workdir}/fixture-pids.log`, `${process.pid}\n`);
process.stdout.write('session_id: slow_fixture_2026\n');
if (/HOLD/.test(prompt)) {
const releaseFile = `${workdir}/hermes-release`;
while (!existsSync(releaseFile)) await new Promise(resolve => setTimeout(resolve, 20));
}
process.stdout.write('Hermes fixture answered the bounded request.\n');
+10 -5
View File
@@ -193,16 +193,21 @@ test('Hermes child receives only an explicit non-secret environment allowlist',
});
});
test('upstream failures and concurrency fail closed without leaking process detail', async () => {
test('saturated turns queue within bounds and excess load fails closed without leaking process detail', async () => {
let release;
const runTurn = () => new Promise(resolve => { release = resolve; });
const service = createHermesAgentService({ config: configured(), randomToken: () => 'browser-cookie', runTurn });
const service = createHermesAgentService({ config: { ...configured(), maxQueueDepth: 1 }, randomToken: () => 'browser-cookie', runTurn });
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
const active = service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'first', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'second', ledger: [] } }), 429);
release({ reply: 'done', sessionId: 'private-session' });
await active;
const queued = service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'second', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
await rejectsStatus(() => service.chat({ origin, cookieToken: 'browser-cookie', payload: { message: 'third', ledger: [] } }), 503);
release({ reply: 'first done', sessionId: 'private-session' });
assert.match((await active).reply, /first done/);
await new Promise(resolve => setImmediate(resolve));
release({ reply: 'queued done', sessionId: 'private-session' });
assert.match((await queued).reply, /queued done/);
const broken = createHermesAgentService({ config: configured(), randomToken: () => 'broken-cookie', runTurn: async () => { throw new Error('spawn /root/.hermes/auth.json SECRET'); } });
await broken.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('a queued request past its deadline is expired deterministically and its place is reusable', async () => {
let clock = 0;
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 100, now: () => clock });
const wedgeGate = deferred();
const active = queue.run(signal => {
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('upstream aborted')));
wedgeGate.promise.then(resolve, reject);
});
});
active.catch(() => {});
await Promise.resolve();
let started = false;
const queued = queue.run(() => { started = true; return 'never'; });
await Promise.resolve();
clock += 150; // deadline (submitted at t=0, limit 100) has elapsed
const probe = queue.run(() => 'ok'); // queue activity triggers the deadline sweep
await assert.rejects(() => queued, error => {
assert.ok(error instanceof OverloadError);
assert.match(error.message, /timed out|busy/i);
return true;
});
assert.equal(started, false, 'the expired request must never reach the provider');
// Capacity is intact: cancelling the wedged holder lets the surviving request through.
queue.cancel(active, 'cleanup');
assert.equal(await Promise.race([probe, new Promise((_, reject) => setTimeout(() => reject(new Error('probe starved')), 200))]), 'ok');
});
test('client disconnect cancels a queued request before it reaches the provider', async () => {
let clock = 0;
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 2, requestTimeoutMs: 60_000, now: () => clock });
const wedgeGate = deferred();
const active = queue.run(() => wedgeGate.promise);
active.catch(() => {});
await Promise.resolve();
let sawStart = false;
const queued = queue.run(() => { sawStart = true; return 'too late'; });
await Promise.resolve();
queue.cancel(queued, 'client disconnected');
await assert.rejects(() => queued, error => error.message === 'client disconnected');
assert.equal(sawStart, false, 'cancelled request never invoked its task');
const admitted = queue.run(() => 'admitted-after-cancel');
wedgeGate.resolve('wedge-done');
assert.equal(await admitted, 'admitted-after-cancel');
assert.equal(await active, 'wedge-done');
});
test('cancelling an already-running task aborts its signal so buffers can be released', async () => {
let clock = 0;
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 60_000, now: () => clock });
const observed = [];
const running = queue.run(async signal => {
observed.push(signal);
signal.addEventListener('abort', () => observed.push(`aborted:${signal.reason?.message ?? signal.reason}`));
await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('task stopped')));
setTimeout(resolve, 5_000);
});
});
// Let the wrapper admit the request and actually invoke the task body.
await Promise.resolve();
await Promise.resolve();
assert.equal(observed.length, 1, 'task is genuinely running before cancellation');
queue.cancel(running, 'stop');
await assert.rejects(() => running, /task stopped/);
assert.ok(observed[0] instanceof AbortSignal, 'task received a real AbortSignal');
assert.match(String(observed[1]), /stop/, 'running task observed the abort with its reason');
// The slot was released by the cancelled task, so the next request starts immediately.
const next = queue.run(() => 'next-runs');
assert.equal(await next, 'next-runs');
});
+136
View File
@@ -0,0 +1,136 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { constants as fsConstants } from 'node:fs';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
const fixture = fileURLToPath(new URL('./fixtures/slow-hermes.mjs', import.meta.url));
const origin = 'http://127.0.0.1:4187';
const accessCode = 'queue-acceptance-code-2026';
async function waitReady(child) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}`);
try { const response = await fetch(`${origin}/api/healthz`); if (response.ok) return; } catch {}
await new Promise(resolve => setTimeout(resolve, 50));
}
throw new Error('server did not become ready');
}
function post(path, body, { cookie = '', signal } = {}) {
return fetch(`${origin}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
origin,
'sec-fetch-site': 'same-origin',
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
signal,
}).then(
async response => ({ status: response.status, body: await response.json().catch(() => ({})) }),
error => ({ failed: true, reason: String(error?.name || error) }),
);
}
test('bounded queue end-to-end: disconnect cleanup, overload fallback, urgent bypass, recovery', async t => {
const workdir = await mkdtemp(join(tmpdir(), 'timmy-queue-accept-'));
const releaseFile = join(workdir, 'hermes-release');
const pidLog = join(workdir, 'fixture-pids.log');
await chmod(fixture, fsConstants.S_IRWXU);
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: {
...process.env,
PORT: '4187',
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
TIMMY_PUBLIC_ORIGIN: origin,
TIMMY_AGENT_WORKDIR: workdir,
TIMMY_HERMES_COMMAND: fixture,
TIMMY_AGENT_MAX_CONCURRENT_TURNS: '1',
TIMMY_AGENT_MAX_QUEUE_DEPTH: '2',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLogs = '';
child.stderr.on('data', chunk => { serverLogs += String(chunk); });
t.after(async () => {
child.kill('SIGTERM');
await rm(workdir, { recursive: true, force: true }).catch(() => {});
await rm(releaseFile, { force: true }).catch(() => {});
});
await waitReady(child);
// Authenticate once and keep the cookie for the whole scenario.
const rawResponse = await fetch(`${origin}/api/agent/unlock`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
body: JSON.stringify({ accessCode }),
});
assert.equal(rawResponse.status, 200);
const browserCookie = rawResponse.headers.get('set-cookie').split(';', 1)[0];
// Fill the slot: this HOLD runs until the fixture release file appears.
const heldOne = post('/api/agent/chat', { message: 'HOLD one', ledger: [] }, { cookie: browserCookie });
await new Promise(resolve => setTimeout(resolve, 400));
// 1. Disconnect cleanup: this request waits in the queue. When its client
// vanishes, Timmy must drop it and free the queued place.
const disconnectController = new AbortController();
const doomed = post('/api/agent/chat', { message: 'HOLD doomed', ledger: [] }, { cookie: browserCookie, signal: disconnectController.signal });
await new Promise(resolve => setTimeout(resolve, 400));
disconnectController.abort();
const doomedOutcome = await doomed;
assert.equal(doomedOutcome.failed, true, 'abandoned client saw its request cancelled');
// If the freed place was reclaimed, both follow-up HOLDs are admitted
// (slot + queue of two minus one freed place). A sanitized 503 here would
// mean the cancelled client did not release capacity.
const heldTwo = post('/api/agent/chat', { message: 'HOLD two', ledger: [] }, { cookie: browserCookie });
const heldThree = post('/api/agent/chat', { message: 'HOLD three', ledger: [] }, { cookie: browserCookie });
await new Promise(resolve => setTimeout(resolve, 500));
// 2. Overload: everything is saturated now, so a further request must fail
// fast with the stable manual-fallback copy and zero provider contact.
const overloadResult = await post('/api/agent/chat', { message: 'overload probe', ledger: [] }, { cookie: browserCookie });
assert.equal(overloadResult.status, 503);
assert.match(overloadResult.body.error, /busy|try again|manually/i);
assert.doesNotMatch(overloadResult.body.error, /spawn|hermes|session|token|workdir|fixture/i);
// 3. Urgent bypass while saturated: deterministic safety answer, zero calls.
const urgentResult = await post('/api/agent/chat', { message: 'I have rectal bleeding', ledger: [] }, { cookie: browserCookie });
assert.equal(urgentResult.status, 200);
assert.equal(urgentResult.body.safetyOverride, true);
assert.match(urgentResult.body.reply, /medical help/i);
// 4. Recovery: releasing the fixture lets every surviving request complete.
await writeFile(releaseFile, 'go');
for (const [label, promise] of [['one', heldOne], ['two', heldTwo], ['three', heldThree]]) {
const settled = await Promise.race([
promise,
new Promise(resolve => setTimeout(() => resolve({ status: 'TEST-TIMEOUT' }), 12_000)),
]);
assert.equal(settled.status, 200, `${label} completed after recovery`);
assert.match(settled.body.reply, /bounded request/);
assert.doesNotMatch(JSON.stringify(settled.body), /slow_fixture_2026|session_id/);
}
// 5. No orphan subprocesses: every fixture PID recorded at spawn is gone.
await new Promise(resolve => setTimeout(resolve, 400));
const recordedPids = ((await readFile(pidLog, 'utf8').catch(() => '')) || '')
.split('\n').map(Number).filter(Boolean);
assert.equal(recordedPids.length, 3, `exactly three turns ran (saw ${recordedPids.length})`);
for (const pid of [...new Set(recordedPids)]) {
let alive = true;
try { process.kill(pid, 0); } catch { alive = false; }
assert.equal(alive, false, `fixture process ${pid} outlived its turn`);
}
assert.doesNotMatch(serverLogs, /access-code|secret|token/i);
}, { timeout: 40_000 });
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('vision analysis runs with bounded concurrency and strict FIFO ordering', async () => {
let clock = 0;
const gates = new Map();
const started = [];
const queue = createInferenceQueue({ concurrency: 2, maxQueueDepth: 4, requestTimeoutMs: 10_000, now: () => clock });
const run = label => queue.run(signal => {
started.push(label);
const gate = deferred();
gates.set(label, gate);
gate.promise.catch(() => {});
return gate.promise;
});
const first = run('first');
const second = run('second');
const third = run('third');
const fourth = run('fourth');
while (started.length < 2) await Promise.resolve();
assert.deepEqual(started, ['first', 'second'], 'only concurrency slots start immediately');
gates.get('first').resolve({ ok: 'first' });
assert.equal((await first).ok, 'first');
// The slot freed by `first` must be granted to `third` (head of the queue).
while (!gates.has('third')) await Promise.resolve();
gates.get('third').resolve({ ok: 'third' });
// Only after `third` releases its slot may `fourth` start.
while (!gates.has('fourth')) await Promise.resolve();
gates.get('second').resolve({ ok: 'second' });
gates.get('fourth').resolve({ ok: 'fourth' });
assert.deepEqual(await Promise.all([second, third, fourth]), [{ ok: 'second' }, { ok: 'third' }, { ok: 'fourth' }]);
assert.equal(started.length, 4, 'every admitted request eventually started');
assert.deepEqual([...new Set(started)], ['first', 'second', 'third', 'fourth'], 'each queued request started exactly once');
assert.equal(started.indexOf('third') < started.indexOf('fourth'), true, 'queued requests were granted in FIFO order');
});
test('overloaded submissions fail fast with a stable sanitized manual-fallback error', async () => {
let clock = 0;
const gates = [];
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 10_000, now: () => clock });
const hold = queue.run(() => { const gate = deferred(); gates.push(gate); gate.promise.catch(() => {}); return gate.promise; });
await Promise.resolve();
const held = queue.run(() => new Promise(() => {}));
await Promise.resolve();
await assert.rejects(() => queue.run(() => new Promise(() => {})), error => {
assert.ok(error instanceof OverloadError);
assert.match(error.message, /busy|full/i);
assert.doesNotMatch(error.message, /stack|internal|fetch|127\.0\.0\.1/i);
return true;
}, 'excess request beyond maxQueueDepth is rejected immediately');
// Active and already-queued work continues unaffected by the rejected submission.
gates[0].resolve('done');
assert.equal(await hold, 'done');
});
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js';
import { analyzePhoto } from '../src/vision-service.js';
import { OverloadError, createInferenceQueue } from '../src/inference-queue.js';
const origin = 'http://127.0.0.1:4173';
const accessCode = 'test-agent-access-code-2026';
const configured = () => resolveHermesAgentConfig({
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
TIMMY_PUBLIC_ORIGIN: origin,
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
});
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
test('urgent messages intercept before Hermes even when the queue is saturated', async () => {
let clock = 0;
let hermesCalls = 0;
const gate = deferred();
const service = createHermesAgentService({
config: { ...configured(), maxQueueDepth: 0 },
randomToken: () => 'urgent-cookie',
now: () => clock,
runTurn: async () => {
hermesCalls += 1;
return gate.promise;
},
});
await service.unlock({ origin, accessCode });
// Saturate the single slot so any queued path would be rejected as overloaded.
const active = service.chat({ origin, cookieToken: 'urgent-cookie', payload: { message: 'normal question', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(hermesCalls, 1);
const urgent = await service.chat({ origin, cookieToken: 'urgent-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } });
assert.equal(hermesCalls, 1, 'Hermes was called exactly once — only for the non-urgent request');
assert.equal(urgent.safetyOverride, true);
assert.match(urgent.reply, /medical help/i);
gate.resolve({ reply: 'late', sessionId: 'urgent_session_01' });
assert.match((await active).reply, /late/);
});
test('vision analysis is bounded by the shared queue and overload returns the manual-fallback copy', async () => {
let clock = 0;
let providerCalls = 0;
const gates = [];
const queue = createInferenceQueue({ concurrency: 1, maxQueueDepth: 1, requestTimeoutMs: 60_000, now: () => clock });
const visionFetch = async (url, options) => {
providerCalls += 1;
const gate = deferred();
gates.push(gate);
options.signal?.addEventListener('abort', () => gate.reject(new Error('aborted')));
return new Promise((resolve, reject) => {
gate.promise.then(() => resolve({ ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.8, imageQuality: 'good', observations: 'ok' }) } }] }) }), reject);
});
};
const payload = { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true };
const config = { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'test-key-not-real', model: 'm' };
const first = queue.run(signal => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal }));
while (providerCalls < 1) await Promise.resolve();
const second = queue.run(signal => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal }));
await Promise.resolve();
await assert.rejects(() => queue.run((signal) => analyzePhoto({ payload, fetchImpl: visionFetch, config, signal })), error => {
assert.ok(error instanceof OverloadError);
assert.match(error.message, /continue manually/i);
assert.doesNotMatch(error.message, /api|key|token|127\.0\.0\.1|fetch/i);
return true;
}, 'third request beyond depth is overloaded with sanitized copy');
assert.equal(providerCalls, 1, 'overloaded request never reached the provider');
gates[0].resolve('go');
const result = await Promise.race([first, new Promise(resolve => setTimeout(() => resolve('pending'), 100))]);
assert.notEqual(result, 'pending');
});
test('vision requests carry cancellation so disconnected clients release provider work', async () => {
let capturedSignal;
const controller = new AbortController();
const gate = deferred();
const fetchImpl = async (url, options) => {
capturedSignal = options.signal;
options.signal?.addEventListener('abort', () => gate.reject(new Error('aborted')));
return gate.promise;
};
const payload = { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true };
const config = { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'test-key-not-real', model: 'm' };
const attempt = analyzePhoto({ payload, fetchImpl, config, signal: controller.signal });
while (!capturedSignal) await Promise.resolve();
controller.abort(new Error('client disconnected'));
await assert.rejects(() => attempt, /continue manually|unavailable/i);
assert.equal(capturedSignal.aborted, true, 'the provider fetch observed the cancellation');
});
+3 -1
View File
@@ -11,6 +11,7 @@ test('release demo visibly explains the CI-protected browser path without overst
assert.match(demo, /Automated checks replay this synthetic path before review/);
assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./);
assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/);
assert.match(demo, /The pinned bootstrap verifies both model files before starting on private loopback/);
assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
assert.match(demo, /Hermes Agent connected/);
assert.match(demo, /Photos stay out of chat/);
@@ -19,9 +20,10 @@ test('release demo visibly explains the CI-protected browser path without overst
assert.match(demo, /SLEEK\. SIMPLE\.<br>HERMES-POWERED\./);
});
test('release builder gates the sleek shell and Hermes chat browser path', async () => {
test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => {
const builder = await readFile(builderPath, 'utf8');
assert.match(builder, /"test:sleek"/);
assert.match(builder, /"sleek_hermes_chat_acceptance": "passed"/);
assert.match(builder, /Sleek three-destination shell/);
assert.match(builder, /"bash", "-n", "scripts\/bootstrap_selfhost_smolvlm\.sh"/);
});
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const script = new URL('../scripts/bootstrap_selfhost_smolvlm.sh', import.meta.url).pathname;
test('self-host bootstrap reports immutable source and model receipts', () => {
const result = spawnSync('bash', [script, 'receipt'], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /llama\.cpp commit: [0-9a-f]{40}/);
assert.match(result.stdout, /SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf sha256: [0-9a-f]{64}/);
assert.match(result.stdout, /mmproj-SmolVLM2-2\.2B-Instruct-Q8_0\.gguf sha256: [0-9a-f]{64}/);
assert.match(result.stdout, /bind address: 127\.0\.0\.1:8080/);
});
test('self-host bootstrap rejects model files that do not match pinned hashes', () => {
const result = spawnSync('bash', [script, 'verify'], {
encoding: 'utf8',
env: { ...process.env, TIMMY_SELFHOST_ROOT: '/tmp/timmy-missing-bootstrap' },
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Run .* install|Missing required file/);
});
test('self-host bootstrap completes the pinned private worker lifecycle from an empty directory', async () => {
const sandbox = await mkdtemp(join(tmpdir(), 'timmy-bootstrap-'));
const bin = join(sandbox, 'bin');
const root = join(sandbox, 'worker');
spawnSync('mkdir', ['-p', bin]);
const commands = join(sandbox, 'commands.log');
const tools = {
git: `#!/bin/sh\nprintf 'git %s\\n' "$*" >> "$COMMAND_LOG"\nif [ "$1" = clone ]; then mkdir -p "$3/.git"; fi\n`,
cmake: `#!/bin/sh\nprintf 'cmake %s\\n' "$*" >> "$COMMAND_LOG"\nbuild=''; prev=''; for arg in "$@"; do [ "$prev" = --build ] && build="$arg"; prev="$arg"; done\nif [ -n "$build" ]; then mkdir -p "$build/bin"; printf '#!/bin/sh\\n' > "$build/bin/llama-server"; chmod +x "$build/bin/llama-server"; fi\n`,
curl: `#!/bin/sh\nprintf 'curl %s\\n' "$*" >> "$COMMAND_LOG"\nout=''; prev=''; for arg in "$@"; do { [ "$prev" = -o ] || [ "$prev" = --output ]; } && out="$arg"; prev="$arg"; done\ncase "$out" in *mmproj*) printf 'mmproj\\n' > "$out";; *) printf 'model\\n' > "$out";; esac\n`,
};
for (const [name, contents] of Object.entries(tools)) {
await writeFile(join(bin, name), contents);
await chmod(join(bin, name), 0o755);
}
const result = spawnSync('bash', [script, 'install'], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
COMMAND_LOG: commands,
TIMMY_SELFHOST_ROOT: root,
TIMMY_MODEL_SHA256: '98ad61a25e3683b6adf2474b01bbe1c27de6aad2ce3a80ff4140fe473c14e691',
TIMMY_MMPROJ_SHA256: 'ce52f16b076dcb922c667c08d62881c85fa26aca7cc60394050d5bb1153b7276',
},
});
assert.equal(result.status, 0, result.stderr);
const log = await readFile(commands, 'utf8');
assert.match(log, /git clone https:\/\/github\.com\/ggml-org\/llama\.cpp/);
assert.match(log, /checkout --detach 6d05498314db1b57f81c271080018aa2d0b89be9/);
assert.match(log, /curl .*SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf/);
assert.match(result.stdout, /Pinned model files verified/);
const fakeServer = join(root, 'llama.cpp', 'build', 'bin', 'llama-server');
await writeFile(fakeServer, `#!/usr/bin/env python3
import argparse, json
from http.server import BaseHTTPRequestHandler, HTTPServer
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--host'); parser.add_argument('--port', type=int)
args, _ = parser.parse_known_args()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({'data': [{'id': 'SmolVLM2-2.2B-Instruct'}]}).encode()
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(body)
def log_message(self, *_): pass
HTTPServer((args.host, args.port), Handler).serve_forever()
`);
await chmod(fakeServer, 0o755);
const lifecycleEnv = {
...process.env,
TIMMY_SELFHOST_ROOT: root,
TIMMY_MODEL_PORT: String(19000 + process.pid % 1000),
TIMMY_MODEL_SHA256: '98ad61a25e3683b6adf2474b01bbe1c27de6aad2ce3a80ff4140fe473c14e691',
TIMMY_MMPROJ_SHA256: 'ce52f16b076dcb922c667c08d62881c85fa26aca7cc60394050d5bb1153b7276',
};
for (const command of ['start', 'health', 'stop']) {
const commandResult = spawnSync('bash', [script, command], { encoding: 'utf8', env: lifecycleEnv });
assert.equal(commandResult.status, 0, `${command}: ${commandResult.stderr}`);
}
await rm(sandbox, { recursive: true, force: true });
});
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, readFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { constants as fsConstants } from 'node:fs';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
const fixture = fileURLToPath(new URL('./fixtures/slow-hermes.mjs', import.meta.url));
test('server shutdown cancels in-flight turns and leaves no orphan Hermes subprocesses', async () => {
const workdir = await mkdtemp(join(tmpdir(), 'timmy-shutdown-'));
await chmod(fixture, fsConstants.S_IRWXU);
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: {
...process.env,
HOST: '127.0.0.1',
PORT: '4188',
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: 'shutdown-accept-code-2026',
TIMMY_PUBLIC_ORIGIN: 'http://127.0.0.1:4188',
TIMMY_AGENT_WORKDIR: workdir,
TIMMY_HERMES_COMMAND: fixture,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let reportedOrigin = '';
child.stdout.on('data', chunk => {
const match = String(chunk).match(/listening on http:\/\/([^:]+):(\d+)/);
if (match && !reportedOrigin) reportedOrigin = `http://${match[1]}:${match[2]}`;
});
const deadline = Date.now() + 10_000;
while (!reportedOrigin && Date.now() < deadline && child.exitCode === null) {
await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(reportedOrigin, 'server reported its port');
const unlockResponse = await fetch(`${reportedOrigin}/api/agent/unlock`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: reportedOrigin, 'sec-fetch-site': 'same-origin' },
body: JSON.stringify({ accessCode: 'shutdown-accept-code-2026' }),
});
assert.equal(unlockResponse.status, 200);
const browserCookie = unlockResponse.headers.get('set-cookie').split(';', 1)[0];
// Start a turn wedged on the fixture's HOLD gate.
fetch(`${reportedOrigin}/api/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: reportedOrigin, 'sec-fetch-site': 'same-origin', cookie: browserCookie },
body: JSON.stringify({ message: 'HOLD shutdown probe', ledger: [] }),
}).catch(() => {});
await new Promise(resolve => setTimeout(resolve, 600));
const pidLog = join(workdir, 'fixture-pids.log');
// Wait until the fixture for this run's HOLD turn is actually alive.
let lastPid = null;
const spawnDeadline = Date.now() + 8_000;
while (Date.now() < spawnDeadline) {
const recorded = ((await readFile(pidLog, 'utf8').catch(() => '')) || '')
.split('\n').map(Number).filter(Boolean);
const candidate = recorded.at(-1);
if (candidate) {
let alive = true;
try { process.kill(candidate, 0); } catch { alive = false; }
if (alive) {
lastPid = candidate;
break;
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
assert.ok(lastPid, 'fixture subprocess was spawned for the in-flight turn');
let aliveBefore = true;
try { process.kill(lastPid, 0); } catch { aliveBefore = false; }
assert.ok(aliveBefore, `fixture ${lastPid} is running before shutdown`);
// Shut the server down while the turn is still running.
child.kill('SIGTERM');
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('server did not exit after SIGTERM')), 5_000);
child.on('exit', code => { clearTimeout(timeout); resolve(code); });
});
// The in-flight fixture must be gone — no orphan subprocess may survive.
await new Promise(resolve => setTimeout(resolve, 500));
let aliveAfter = true;
try { process.kill(lastPid, 0); } catch { aliveAfter = false; }
assert.equal(aliveAfter, false, `fixture process ${lastPid} survived server shutdown`);
await rm(workdir, { recursive: true, force: true }).catch(() => {});
}, { timeout: 30_000 });
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
const source = await readFile(new URL('../service-worker.js', import.meta.url), 'utf8');
function loadWorker({ scope = 'https://example.test/', cacheKeys = [], currentHits = new Map(), fetchImpl = async () => { throw new Error('offline'); } } = {}) {
const listeners = new Map();
const deleted = [];
const puts = [];
let cacheOpenCount = 0;
const currentCache = {
addAll: async () => {},
put: async request => { puts.push(typeof request === 'string' ? request : request.url); },
match: async request => currentHits.get(typeof request === 'string' ? request : request.url),
};
const caches = {
keys: async () => cacheKeys,
delete: async key => { deleted.push(key); return true; },
open: async () => { cacheOpenCount += 1; return currentCache; },
match: async () => { throw new Error('global cache matching must not be used'); },
};
const self = {
registration: { scope },
addEventListener: (type, handler) => listeners.set(type, handler),
skipWaiting: async () => {},
clients: { claim: async () => {} },
};
vm.runInNewContext(source, { self, caches, fetch: fetchImpl, URL, Promise, String });
return { listeners, deleted, puts, getCacheOpenCount: () => cacheOpenCount };
}
async function dispatchExtendable(handler) {
let pending;
handler({ waitUntil(value) { pending = value; } });
await pending;
}
async function dispatchFetch(handler, request) {
let response;
handler({ request, respondWith(value) { response = value; } });
return response;
}
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
const { listeners, deleted } = loadWorker({
cacheKeys: [
'timmy-shell-v4',
'timmy-shell:/:v4',
'timmy-shell:/:v5',
'timmy-shell:/timmy-staging/:v4',
'sibling-shared-origin-cache',
],
});
await dispatchExtendable(listeners.get('activate'));
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
});
test('offline shell lookup uses only the current named cache', async () => {
const request = { method: 'GET', url: 'https://example.test/app.js' };
const current = { body: 'current app' };
const { listeners } = loadWorker({ currentHits: new Map([[request.url, current]]) });
const response = await dispatchFetch(listeners.get('fetch'), request);
assert.equal(await response, current);
});
test('scoped API GET requests bypass the shell cache entirely', async () => {
const request = { method: 'GET', url: 'https://example.test/timmy-staging/api/healthz' };
const networkResponse = { headers: { get: () => 'no-store' }, clone: () => ({}) };
const { listeners, puts, getCacheOpenCount } = loadWorker({
scope: 'https://example.test/timmy-staging/',
fetchImpl: async () => networkResponse,
});
const response = await dispatchFetch(listeners.get('fetch'), request);
assert.equal(await response, networkResponse);
assert.equal(getCacheOpenCount(), 0);
assert.deepEqual(puts, []);
});
test('no-store shell responses are returned without being cached', async () => {
const request = { method: 'GET', url: 'https://example.test/styles.css' };
const networkResponse = { headers: { get: () => 'private, no-store' }, clone: () => ({}) };
const { listeners, puts } = loadWorker({ fetchImpl: async () => networkResponse });
const response = await dispatchFetch(listeners.get('fetch'), request);
await response;
await Promise.resolve();
assert.deepEqual(puts, []);
});
+71 -1
View File
@@ -3,12 +3,16 @@ import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173/';
const expectedBasePath = new URL(appUrl).pathname.replace(/\/$/, '') || '/';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
const apiRequests = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
page.on('request', request => { if (new URL(request.url()).pathname.includes('/api/')) apiRequests.push(new URL(request.url()).pathname); });
await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'SmolVLM2-2.2B-Instruct' }) }));
await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }) }));
@@ -18,13 +22,18 @@ await page.route('**/api/agent/chat', async route => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4 this week. I can explain the pattern, but I cannot diagnose a cause.' }) });
});
await page.goto('http://127.0.0.1:4173');
await page.goto(appUrl);
await page.waitForLoadState('networkidle');
assert.equal(await page.locator('meta[name="timmy-base-path"]').getAttribute('content'), expectedBasePath);
assert.equal(await page.locator('.bottom-nav .nav-btn').count(), 3, 'primary navigation must have exactly three destinations');
assert.equal(await page.locator('main .btn-primary:visible').count(), 1, 'home must expose one dominant primary action');
assert.equal(await page.locator('[data-log]:visible').count(), 1, 'manual fallback remains available once without competing styling');
assert.match(await page.locator('main').innerText(), /photo/i);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'home must not overflow horizontally');
if (process.env.TIMMY_EXPECT_STAGING_LABEL) {
assert.match(await page.locator('.staging-label').innerText(), /Staging · daily-test · [0-9a-f]{12}/);
assert.equal(await page.locator('.staging-label').evaluate(node => node.tagName), 'FOOTER');
}
await page.screenshot({ path: 'artifacts/sleek-home-mobile.png', fullPage: true });
await page.locator('[data-view="timmy"]').click();
@@ -45,6 +54,67 @@ assert.equal(chatRequest, previousRequest, 'urgent language must be intercepted
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'chat must not overflow horizontally');
await page.screenshot({ path: 'artifacts/sleek-hermes-chat-mobile.png', fullPage: true });
await page.locator('[data-view="home"]').click();
await page.locator('[data-log]').click();
await page.locator('#next').click();
await page.locator('#next').click();
await page.locator('#save').click();
assert.deepEqual(await page.evaluate(() => Object.keys(localStorage)), [`timmy:${expectedBasePath}:ledger-v1`]);
const serviceWorkerContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const serviceWorkerPage = await serviceWorkerContext.newPage();
await serviceWorkerPage.goto(new URL('/git', appUrl).toString());
await serviceWorkerPage.evaluate(async () => {
await caches.open('sibling-shared-origin-cache');
await caches.open('timmy-shell-v5:/');
});
await serviceWorkerPage.goto(appUrl);
const registration = await serviceWorkerPage.evaluate(async () => {
const ready = await Promise.race([navigator.serviceWorker.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('service worker timeout')), 5000))]);
return { scope: ready.scope, scriptURL: ready.active?.scriptURL || '', cacheKeys: await caches.keys() };
});
assert.ok(registration.cacheKeys.includes('sibling-shared-origin-cache'), 'Timmy service worker must preserve sibling application caches');
if (expectedBasePath !== '/') assert.ok(registration.cacheKeys.includes('timmy-shell-v5:/'), 'prefixed Timmy must preserve the root Timmy cache');
await serviceWorkerContext.close();
const expectedRoot = expectedBasePath === '/' ? '/' : `${expectedBasePath}/`;
assert.equal(new URL(registration.scope).pathname, expectedRoot);
assert.equal(new URL(registration.scriptURL).pathname, `${expectedRoot}service-worker.js`);
assert.ok(apiRequests.length >= 2);
assert.ok(apiRequests.every(path => path.startsWith(`${expectedRoot}api/`)), `API request escaped prefix: ${apiRequests.join(', ')}`);
if (expectedBasePath === '/') {
const migrationContext = await browser.newContext({ serviceWorkers: 'block' });
await migrationContext.addInitScript(() => {
const entry = id => ({
id,
occurredAt: new Date().toISOString(),
bristolType: 4,
color: 'brown',
urgency: 0,
discomfort: 0,
note: '',
symptoms: {},
});
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([entry('current-root-entry')]));
localStorage.setItem('timmy-ledger-v1', JSON.stringify([entry('legacy-root-entry')]));
localStorage.setItem('unrelated-sibling-data', 'preserve-me');
});
const migrationPage = await migrationContext.newPage();
await migrationPage.goto(appUrl);
assert.equal(await migrationPage.locator('.glance div').last().locator('strong').innerText(), '1');
assert.match(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')), /current-root-entry/);
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
await migrationPage.evaluate(() => localStorage.setItem('timmy-ledger-v1', '[{"id":"resurrection-risk"}]'));
await migrationPage.getByRole('button', { name: 'Journal', exact: true }).click();
await migrationPage.locator('[data-view="privacy"]').click();
migrationPage.once('dialog', dialog => dialog.accept());
await migrationPage.locator('#delete-all').click();
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')), null);
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('unrelated-sibling-data')), 'preserve-me');
await migrationContext.close();
}
assert.deepEqual(errors, []);
await browser.close();
console.log('Sleek shell + Hermes chat mobile acceptance passed.');
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
const servicePath = new URL('../deploy/timmy-staging.service', import.meta.url);
const envPath = new URL('../deploy/timmy-staging.env.example', import.meta.url);
const caddyPath = new URL('../deploy/Caddyfile.staging.example', import.meta.url);
const runbookPath = new URL('../docs/STAGING-RUNBOOK.md', import.meta.url);
const literalSecret = /(password|token|secret|api[_-]?key)\s*[=:]\s*(?!\$\{|<|CHANGE_ME|false|$)["']?[A-Za-z0-9/+_.-]{8,}/i;
test('systemd template runs a dedicated loopback-only agent-disabled service', async () => {
const service = await readFile(servicePath, 'utf8');
for (const directive of [
'User=timmy-staging', 'Group=timmy-staging',
'WorkingDirectory=/opt/timmy-staging/current',
'EnvironmentFile=/etc/timmy-staging.env',
'Environment=HOST=127.0.0.1', 'Environment=PORT=4174',
'Environment=TIMMY_BASE_PATH=/timmy-staging',
'Environment=TIMMY_AGENT_ENABLED=false',
]) assert.match(service, new RegExp(`^${directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), directive);
assert.match(service, /^ExecStart=\/usr\/bin\/env TIMMY_AGENT_ENABLED=false TIMMY_VISION_ENABLED=false \/usr\/local\/lib\/timmy-staging\/node server\.mjs$/m);
assert.doesNotMatch(service, /ExecStart=\/usr\/(?:local\/)?bin\/node|0\.0\.0\.0|TIMMY_AGENT_ENABLED=true/);
assert.doesNotMatch(service, literalSecret);
});
test('systemd command-level environment keeps agent and vision disabled after EnvironmentFile overrides', async () => {
const service = await readFile(servicePath, 'utf8');
const environmentFileIndex = service.indexOf('EnvironmentFile=');
const execLine = service.match(/^ExecStart=(.+)$/m)?.[1];
assert.ok(execLine, 'ExecStart must exist');
assert.ok(environmentFileIndex < service.indexOf(`ExecStart=${execLine}`), 'EnvironmentFile must be loaded before command overrides');
const argv = execLine.trim().split(/\s+/);
assert.equal(argv.shift(), '/usr/bin/env');
const assignments = argv.filter(value => /^TIMMY_(?:AGENT|VISION)_ENABLED=/.test(value));
assert.deepEqual(assignments, ['TIMMY_AGENT_ENABLED=false', 'TIMMY_VISION_ENABLED=false']);
const probe = spawnSync('/usr/bin/env', [
...assignments, process.execPath, '-e',
'process.stdout.write(`${process.env.TIMMY_AGENT_ENABLED},${process.env.TIMMY_VISION_ENABLED}`)',
], { env: { ...process.env, TIMMY_AGENT_ENABLED: 'true', TIMMY_VISION_ENABLED: 'true' }, encoding: 'utf8' });
assert.equal(probe.status, 0, probe.stderr);
assert.equal(probe.stdout, 'false,false');
});
test('systemd template applies filesystem privilege process and resource confinement', async () => {
const service = await readFile(servicePath, 'utf8');
for (const directive of [
'UMask=0077', 'NoNewPrivileges=true', 'PrivateTmp=true', 'PrivateDevices=true',
'ProtectSystem=strict', 'ProtectHome=true', 'ReadWritePaths=/var/lib/timmy-staging',
'RestrictSUIDSGID=true', 'LockPersonality=true', 'RestrictNamespaces=true',
'ProtectKernelTunables=true', 'ProtectKernelModules=true', 'ProtectKernelLogs=true',
'ProtectControlGroups=true', 'ProtectClock=true', 'ProtectHostname=true',
'CapabilityBoundingSet=', 'AmbientCapabilities=', 'RestrictRealtime=true',
'TasksMax=64', 'MemoryMax=512M', 'LimitNOFILE=1024',
]) assert.match(service, new RegExp(`^${directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), directive);
assert.equal((service.match(/^ReadWritePaths=/gm) || []).length, 1);
});
test('environment example is least privilege base-path staging configuration without credentials', async () => {
const env = await readFile(envPath, 'utf8');
for (const setting of [
'HOST=127.0.0.1', 'PORT=4174', 'TIMMY_BASE_PATH=/timmy-staging',
'TIMMY_STAGING_LABEL=true', 'TIMMY_AGENT_ENABLED=false', 'TIMMY_VISION_ENABLED=false',
]) assert.match(env, new RegExp(`^${setting.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
assert.match(env, /install -m 600/);
assert.doesNotMatch(env, /TIMMY_AGENT_ACCESS_TOKEN|GITEA_TOKEN|OPENAI_API_KEY|COOKIE/);
assert.doesNotMatch(env, literalSecret);
});
test('Caddy snippet isolates git and authenticates staging before a catchall', async () => {
const caddy = await readFile(caddyPath, 'utf8');
const git = caddy.indexOf('handle @git');
const staging = caddy.indexOf('handle_path /timmy-staging/*');
const catchall = caddy.indexOf('handle {');
assert.ok(git >= 0 && staging > git && catchall > staging, 'route order must be git, staging, catchall');
assert.match(caddy, /basic_auth/);
assert.match(caddy, /\{\$TIMMY_STAGING_PASSWORD_HASH\}/);
assert.match(caddy, /reverse_proxy 127\.0\.0\.1:4174/);
assert.match(caddy, /max_size 8MB/);
assert.match(caddy, /X-Content-Type-Options "nosniff"/);
assert.match(caddy, /X-Frame-Options "DENY"/);
assert.match(caddy, /Referrer-Policy "no-referrer"/);
assert.match(caddy, /Content-Security-Policy/);
assert.match(caddy, /rewrite \* \/timmy-staging\{uri\}/, 'handle_path strips once, then upstream base path is explicitly reconstructed');
assert.doesNotMatch(caddy, /\$2[aby]\$[A-Za-z0-9./]{20,}|password\s+[^<{\s]/i);
});
test('runbook covers safe installation operation verification rollback backup and removal', async () => {
const runbook = await readFile(runbookPath, 'utf8');
for (const heading of [
'Prerequisites', 'DNS and URL', 'Install', 'Promote', 'Smoke test', 'Status and logs',
'Rollback', 'Backup', 'Remove staging', 'Template validation',
]) assert.match(runbook, new RegExp(`^## .*${heading}`, 'mi'), heading);
assert.match(runbook, /sha256/i);
assert.match(runbook, /install -D -o root -g root -m 755[^\n]+\/usr\/local\/lib\/timmy-staging\/node/);
assert.match(runbook, /\/usr\/local\/lib\/timmy-staging\/node --version/);
assert.match(runbook, /chmod 600|install -m 600/);
assert.match(runbook, /systemctl restart timmy-staging\.service/);
assert.match(runbook, /no prior release[^.]*stops[^.]*service/i);
assert.match(runbook, /failed release[^.]*inert[^.]*evidence/i);
assert.match(runbook, /journalctl -u timmy-staging\.service/);
assert.match(runbook, /do not.*live|approval/i);
assert.doesNotMatch(runbook, literalSecret);
});
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""Filesystem and failure-path tests for immutable staging deployment."""
from __future__ import annotations
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "deploy_staging.py"
COMMIT_A = "a" * 40
COMMIT_B = "b" * 40
def load_deploy():
spec = importlib.util.spec_from_file_location("deploy_staging", SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def make_archive(path: Path, members: list[tuple[str, bytes, str]]) -> str:
with tarfile.open(path, "w:gz") as archive:
for name, body, kind in members:
info = tarfile.TarInfo(name)
if kind == "file":
info.size = len(body)
archive.addfile(info, io.BytesIO(body))
elif kind == "dir":
info.type = tarfile.DIRTYPE
archive.addfile(info)
elif kind == "symlink":
info.type = tarfile.SYMTYPE
info.linkname = "server.mjs"
archive.addfile(info)
elif kind == "hardlink":
info.type = tarfile.LNKTYPE
info.linkname = "server.mjs"
archive.addfile(info)
elif kind == "fifo":
info.type = tarfile.FIFOTYPE
archive.addfile(info)
elif kind == "device":
info.type = tarfile.CHRTYPE
archive.addfile(info)
return hashlib.sha256(path.read_bytes()).hexdigest()
class DeployTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "opt"
self.root.mkdir()
self.deploy = load_deploy()
self.config = self.deploy.DeploymentConfig(
root=self.root,
restart_command=("fixture-restart",),
stop_command=("fixture-stop",),
smoke_command=("fixture-smoke",),
health_url="http://127.0.0.1:4174/api/healthz",
command_timeout=1.0,
health_timeout=1.0,
)
self.commands = []
def tearDown(self):
self.tmp.cleanup()
def runner(self, argv, **kwargs):
self.commands.append((tuple(argv), kwargs))
return subprocess.CompletedProcess(argv, 0, "", "")
def healthy(self, url, commit, timeout):
self.assertEqual(url, self.config.health_url)
self.assertEqual(timeout, self.config.health_timeout)
return {"ok": True, "commit": commit}
def archive(self, name="release.tar.gz", members=None):
path = Path(self.tmp.name) / name
digest = make_archive(path, members or [
("timmy-release/", b"", "dir"),
("timmy-release/server.mjs", b"console.log('ok')\n", "file"),
("timmy-release/package.json", b"{}\n", "file"),
])
return path, digest
def seed_release(self, commit):
release = self.root / "releases" / commit
release.mkdir(parents=True)
(release / "server.mjs").write_text("ok", encoding="utf-8")
(release / ".timmy-release.json").write_text(json.dumps({"commit": commit, "tag": "old"}), encoding="utf-8")
return release
def point_current(self, commit):
(self.root / "current").symlink_to(Path("releases") / commit)
def promote(self, archive, digest, commit=COMMIT_B, **kwargs):
return self.deploy.promote(
config=self.config, tag="daily-test", archive=archive,
expected_sha256=digest, commit=commit,
run_command=kwargs.get("run_command", self.runner),
health_check=kwargs.get("health_check", self.healthy),
)
def test_checksum_mismatch_fails_before_tar_is_opened(self):
archive = Path(self.tmp.name) / "not-even-a-tar"
archive.write_bytes(b"untrusted")
with self.assertRaisesRegex(self.deploy.DeploymentError, "SHA-256 mismatch"):
self.promote(archive, "0" * 64)
self.assertFalse((self.root / "releases").exists())
def test_verified_private_archive_copy_is_used_after_caller_archive_mutates(self):
archive, digest = self.archive("mutable.tar.gz")
evil = Path(self.tmp.name) / "evil.tar.gz"
make_archive(evil, [
("timmy-release/", b"", "dir"),
("timmy-release/server.mjs", b"EVIL\n", "file"),
])
real_inspect = self.deploy.inspect_archive
inspected_paths = []
def mutate_then_inspect(path, config):
inspected_paths.append(Path(path))
archive.write_bytes(evil.read_bytes())
return real_inspect(path, config)
with mock.patch.object(self.deploy, "inspect_archive", side_effect=mutate_then_inspect):
self.promote(archive, digest)
release = self.root / "releases" / COMMIT_B
self.assertNotEqual(inspected_paths, [archive])
self.assertEqual((release / "server.mjs").read_text(), "console.log('ok')\n")
self.assertTrue(all(not path.exists() for path in inspected_paths), "verified temp archive must be cleaned")
def test_archive_source_must_be_a_nonsymlink_regular_file(self):
archive, digest = self.archive("regular.tar.gz")
symlink = Path(self.tmp.name) / "archive-link.tar.gz"
symlink.symlink_to(archive)
for source in (symlink, Path(self.tmp.name)):
with self.subTest(source=source), self.assertRaisesRegex(self.deploy.DeploymentError, "regular file|stage archive"):
self.promote(source, digest)
def test_absolute_and_traversal_paths_are_rejected(self):
for index, unsafe in enumerate(("/etc/passwd", "root/../../escape", "../escape", "root//double")):
archive, digest = self.archive(f"unsafe-{index}.tar.gz", [(unsafe, b"bad", "file")])
with self.subTest(unsafe=unsafe), self.assertRaisesRegex(self.deploy.DeploymentError, "unsafe archive path"):
self.promote(archive, digest)
self.assertFalse((self.root / "releases" / COMMIT_B).exists())
def test_links_devices_fifos_and_oversized_archives_are_rejected(self):
for index, kind in enumerate(("symlink", "hardlink", "fifo", "device")):
archive, digest = self.archive(f"special-{index}.tar.gz", [(f"root/bad-{kind}", b"", kind)])
with self.subTest(kind=kind), self.assertRaisesRegex(self.deploy.DeploymentError, "unsupported archive member"):
self.promote(archive, digest)
archive, digest = self.archive("large.tar.gz", [("root/large", b"x" * 17, "file")])
tiny = self.deploy.DeploymentConfig(**{**self.config.__dict__, "max_member_bytes": 16})
with self.assertRaisesRegex(self.deploy.DeploymentError, "member size"):
self.deploy.promote(config=tiny, tag="daily-test", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=self.runner, health_check=self.healthy)
def test_member_count_limit_is_enforced(self):
archive, digest = self.archive("many.tar.gz", [(f"root/{i}", b"x", "file") for i in range(3)])
tiny = self.deploy.DeploymentConfig(**{**self.config.__dict__, "max_members": 2})
with self.assertRaisesRegex(self.deploy.DeploymentError, "member count"):
self.deploy.promote(config=tiny, tag="daily-test", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=self.runner, health_check=self.healthy)
def test_secrets_env_and_forbidden_artifacts_are_rejected(self):
forbidden = ("root/.env", "root/secrets/token.txt", "root/private.pem", "root/model.gguf", "root/.git/config", "root/video/raw.webm")
for index, name in enumerate(forbidden):
archive, digest = self.archive(f"secret-{index}.tar.gz", [(name, b"secret", "file")])
with self.subTest(name=name), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
self.promote(archive, digest)
def test_forbidden_common_roots_are_rejected_case_insensitively_but_release_wrapper_is_allowed(self):
for index, root in enumerate((".git", "ViDeO", "ARTIFACTS", "Credentials")):
archive, _ = self.archive(f"forbidden-root-{index}.tar.gz", [
(f"{root}/", b"", "dir"),
(f"{root}/server.mjs", b"evil", "file"),
])
with self.subTest(root=root), self.assertRaisesRegex(self.deploy.DeploymentError, "forbidden archive artifact"):
self.deploy.inspect_archive(archive, self.config)
archive, _ = self.archive("normal-wrapper.tar.gz")
_, wrapper = self.deploy.inspect_archive(archive, self.config)
self.assertEqual(wrapper, "timmy-release")
def test_release_root_symlink_is_rejected(self):
outside = Path(self.tmp.name) / "outside"
outside.mkdir()
(self.root / "releases").symlink_to(outside, target_is_directory=True)
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "releases directory"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_deployment_root_symlink_is_rejected_before_writing_outside(self):
outside = Path(self.tmp.name) / "outside-root"
outside.mkdir()
self.root.rmdir()
self.root.symlink_to(outside, target_is_directory=True)
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_deployment_root_non_directory_is_rejected(self):
self.root.rmdir()
self.root.write_text("not a directory")
archive, digest = self.archive()
with self.assertRaisesRegex(self.deploy.DeploymentError, "deployment root is not a directory"):
self.promote(archive, digest)
def test_current_and_pending_symlink_boundaries_are_rejected_before_extraction(self):
outside = Path(self.tmp.name) / "outside-boundary"
outside.mkdir()
(self.root / "releases").mkdir()
(self.root / "current").symlink_to(outside, target_is_directory=True)
archive, digest = self.archive("current-boundary.tar.gz")
with self.assertRaisesRegex(self.deploy.DeploymentError, "current symlink escapes"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
(self.root / "current").unlink()
pending = self.root / "releases" / f".pending-{COMMIT_B}-fixed"
pending.symlink_to(outside, target_is_directory=True)
with mock.patch.object(self.deploy.secrets, "token_hex", return_value="fixed"):
with self.assertRaisesRegex(self.deploy.DeploymentError, "pending release boundary"):
self.promote(archive, digest)
self.assertEqual(list(outside.iterdir()), [])
def test_valid_archive_extracts_to_commit_release_and_is_never_overwritten(self):
archive, digest = self.archive()
result = self.promote(archive, digest)
release = self.root / "releases" / COMMIT_B
self.assertEqual(result["commit"], COMMIT_B)
self.assertEqual((release / "server.mjs").read_text(), "console.log('ok')\n")
self.assertEqual(json.loads((release / ".timmy-release.json").read_text())["sha256"], digest)
with self.assertRaisesRegex(self.deploy.DeploymentError, "already exists"):
self.promote(archive, digest)
def test_success_atomically_swaps_current_and_uses_bounded_argv_commands(self):
self.seed_release(COMMIT_A)
self.point_current(COMMIT_A)
archive, digest = self.archive()
self.promote(archive, digest)
self.assertEqual((self.root / "current").resolve(), self.root / "releases" / COMMIT_B)
self.assertEqual([command for command, _ in self.commands], [("fixture-restart",), ("fixture-smoke",)])
self.assertTrue(all(options["shell"] is False and options["timeout"] == 1.0 for _, options in self.commands))
self.assertFalse(any(path.name.startswith(".current-") for path in self.root.iterdir()))
def test_restart_health_and_smoke_failures_automatically_restore_prior_release(self):
phases = ("restart", "health", "smoke")
for phase in phases:
with self.subTest(phase=phase):
root = Path(self.tmp.name) / phase
config = self.deploy.DeploymentConfig(**{**self.config.__dict__, "root": root})
release = root / "releases" / COMMIT_A
release.mkdir(parents=True)
(release / "server.mjs").write_text("ok")
(root / "current").symlink_to(Path("releases") / COMMIT_A)
archive, digest = self.archive(f"{phase}.tar.gz")
calls = []
def run(argv, **kwargs):
calls.append(tuple(argv))
if (phase == "restart" and len(calls) == 1) or (phase == "smoke" and tuple(argv) == ("fixture-smoke",)):
raise subprocess.CalledProcessError(1, argv)
return subprocess.CompletedProcess(argv, 0, "", "")
def health(url, commit, timeout):
if phase == "health" and commit == COMMIT_B:
raise self.deploy.DeploymentError("health failed")
return {"ok": True, "commit": commit}
with self.assertRaisesRegex(self.deploy.DeploymentError, "promotion verification failed"):
self.deploy.promote(config=config, tag="daily-test", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=run, health_check=health)
self.assertEqual((root / "current").resolve(), root / "releases" / COMMIT_A)
self.assertGreaterEqual(calls.count(("fixture-restart",)), 2)
def test_first_promotion_failure_removes_current_and_stops_service_without_restart(self):
archive, digest = self.archive("first-failure.tar.gz")
calls = []
def run(argv, **kwargs):
calls.append(tuple(argv))
if tuple(argv) == ("fixture-smoke",):
raise subprocess.CalledProcessError(1, argv)
return subprocess.CompletedProcess(argv, 0, "", "")
with self.assertRaisesRegex(self.deploy.DeploymentError, "no prior release; service stopped"):
self.promote(archive, digest, run_command=run)
self.assertFalse((self.root / "current").exists())
self.assertFalse((self.root / "current").is_symlink())
self.assertEqual(calls, [("fixture-restart",), ("fixture-smoke",), ("fixture-stop",)])
self.assertTrue((self.root / "releases" / COMMIT_B).is_dir(), "failed release remains inert evidence")
def test_rollback_requires_valid_immutable_release_and_restarts_and_checks_health(self):
self.seed_release(COMMIT_A)
self.seed_release(COMMIT_B)
self.point_current(COMMIT_B)
result = self.deploy.rollback(config=self.config, commit=COMMIT_A, run_command=self.runner, health_check=self.healthy)
self.assertEqual(result["commit"], COMMIT_A)
self.assertEqual((self.root / "current").resolve(), self.root / "releases" / COMMIT_A)
with self.assertRaisesRegex(self.deploy.DeploymentError, "does not exist"):
self.deploy.rollback(config=self.config, commit="c" * 40, run_command=self.runner, health_check=self.healthy)
tampered = self.seed_release("d" * 40)
(tampered / ".timmy-release.json").write_text(json.dumps({"commit": COMMIT_A, "tag": "wrong"}))
with self.assertRaisesRegex(self.deploy.DeploymentError, "metadata"):
self.deploy.rollback(config=self.config, commit="d" * 40, run_command=self.runner, health_check=self.healthy)
def test_default_command_adapter_accepts_verifier_kwargs_and_stays_bounded(self):
result = self.deploy._run_argv(
(sys.executable, "-c", "print('ok')"), check=True, text=True,
capture_output=True, shell=False, timeout=1.0,
)
self.assertEqual(result.stdout.strip(), "ok")
def test_status_and_cli_dry_run_are_rootless_and_machine_readable(self):
self.seed_release(COMMIT_A)
self.point_current(COMMIT_A)
status = self.deploy.status(self.config)
self.assertEqual(status["commit"], COMMIT_A)
run = subprocess.run([
sys.executable, str(SCRIPT), "--root", str(self.root), "--dry-run", "status"
], text=True, capture_output=True, check=False)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual(json.loads(run.stdout)["commit"], COMMIT_A)
def test_commit_tag_and_checksum_arguments_are_strictly_validated(self):
archive, digest = self.archive()
for commit in ("abc", "A" * 40, "a" * 41, "../" + "a" * 40):
with self.subTest(commit=commit), self.assertRaisesRegex(self.deploy.DeploymentError, "commit"):
self.promote(archive, digest, commit=commit)
with self.assertRaisesRegex(self.deploy.DeploymentError, "tag"):
self.deploy.promote(config=self.config, tag="../bad", archive=archive, expected_sha256=digest, commit=COMMIT_B, run_command=self.runner, health_check=self.healthy)
if __name__ == "__main__":
unittest.main()
+167
View File
@@ -0,0 +1,167 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chmod, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
const hermesFixture = fileURLToPath(new URL('./fixtures/fake-hermes.mjs', import.meta.url));
let nextPort = 43100;
async function startServer(t, env = {}) {
const port = nextPort++;
const origin = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: String(port), ...env, ...(env.TIMMY_PUBLIC_ORIGIN === '__ORIGIN__' ? { TIMMY_PUBLIC_ORIGIN: origin } : {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let stdout = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
t.after(() => child.kill('SIGTERM'));
const deadline = Date.now() + 10_000;
const basePath = (env.TIMMY_BASE_PATH || '').replace(/\/$/, '');
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}: ${stderr}`);
try {
const response = await fetch(`${origin}${basePath}/api/healthz`);
if (response.status) return { origin, child, getStdout: () => stdout };
} catch {}
await new Promise(resolve => setTimeout(resolve, 40));
}
throw new Error(`server did not become ready: ${stderr}`);
}
test('staging host can bind loopback instead of every network interface', async t => {
const { getStdout } = await startServer(t, { HOST: '127.0.0.1', TIMMY_VISION_ENABLED: '0' });
assert.match(getStdout(), /http:\/\/127\.0\.0\.1:/);
assert.doesNotMatch(getStdout(), /http:\/\/0\.0\.0\.0:/);
});
test('health endpoint exposes only bounded staging identity and feature flags', async t => {
const { origin } = await startServer(t, {
TIMMY_RELEASE_TAG: 'daily-2026-08-20.3',
TIMMY_RELEASE_COMMIT: 'ca31e6d38bec649407f63880504554c59f2878ae',
TIMMY_VISION_ENABLED: '0',
TIMMY_AGENT_ENABLED: 'false',
SECRET_TOKEN: 'must-not-leak',
});
const response = await fetch(`${origin}/api/healthz`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
ok: true,
release: 'daily-2026-08-20.3',
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
visionEnabled: false,
agentEnabled: false,
});
assert.deepEqual([...response.headers.keys()].filter(name => /token|cookie|session|path|environment|credential/i.test(name)), []);
});
test('base path contains static files and APIs without capturing sibling routes', async t => {
const { origin } = await startServer(t, { TIMMY_BASE_PATH: '/timmy-staging', TIMMY_VISION_ENABLED: '0' });
const health = await fetch(`${origin}/timmy-staging/api/healthz`);
assert.equal(health.status, 200);
const page = await fetch(`${origin}/timmy-staging/`);
assert.equal(page.status, 200);
assert.match(await page.text(), /<div id="app"/);
assert.equal((await fetch(`${origin}/timmy-staging/app.js`)).status, 200);
assert.equal((await fetch(`${origin}/api/healthz`)).status, 404);
assert.equal((await fetch(`${origin}/git`)).status, 404);
});
test('prefixed document, manifest, and service worker stay inside the app scope', async t => {
const { origin } = await startServer(t, {
TIMMY_BASE_PATH: '/timmy-staging',
TIMMY_RELEASE_TAG: 'daily-2026-08-20.3',
TIMMY_RELEASE_COMMIT: 'ca31e6d38bec649407f63880504554c59f2878ae',
TIMMY_STAGING_LABEL: 'true',
TIMMY_VISION_ENABLED: '0',
});
const redirect = await fetch(`${origin}/timmy-staging`, { redirect: 'manual' });
assert.equal(redirect.status, 308);
assert.equal(redirect.headers.get('location'), '/timmy-staging/');
const html = await (await fetch(`${origin}/timmy-staging/`)).text();
assert.match(html, /<base href="\/timmy-staging\/">/);
assert.doesNotMatch(html, /window\.__TIMMY_CONFIG__\s*=/);
assert.match(html, /<meta name="timmy-base-path" content="\/timmy-staging">/);
assert.match(html, /<meta name="timmy-staging-label" content="Staging · daily-2026-08-20\.3 · ca31e6d38bec">/);
const manifest = await (await fetch(`${origin}/timmy-staging/manifest.webmanifest`)).json();
assert.equal(manifest.start_url, '/timmy-staging/');
assert.equal(manifest.scope, '/timmy-staging/');
assert.ok(manifest.icons.every(icon => icon.src.startsWith('/timmy-staging/')));
assert.equal((await fetch(`${origin}/timmy-staging/service-worker.js`)).status, 200);
});
test('agent cookie is constrained to the normalized base path', async t => {
const workdir = await mkdtemp(join(tmpdir(), 'timmy-staging-cookie-'));
await chmod(hermesFixture, 0o700);
t.after(() => rm(workdir, { recursive: true, force: true }));
const { origin } = await startServer(t, {
TIMMY_BASE_PATH: '/timmy-staging/',
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: 'test-integration-access-code-2026',
TIMMY_PUBLIC_ORIGIN: '__ORIGIN__',
TIMMY_AGENT_WORKDIR: workdir,
TIMMY_HERMES_COMMAND: hermesFixture,
TIMMY_VISION_ENABLED: '0',
});
const response = await fetch(`${origin}/timmy-staging/api/agent/unlock`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
body: JSON.stringify({ accessCode: 'test-integration-access-code-2026' }),
});
assert.equal(response.status, 200);
assert.match(response.headers.get('set-cookie'), /; Path=\/timmy-staging\//);
});
test('malformed and escaping base paths are rejected at startup', async () => {
const invalid = [
'timmy-staging',
'/../git',
'/timmy/./nested',
'/%2e%2e/git',
'/%252e%252e/git',
'/timmy%2Fgit',
'/timmy%5cgit',
'/timmy%2dstaging',
'/timmy\\git',
'/timmy?debug=1',
'/timmy#fragment',
'/timmy%',
'/timmy//nested',
'/timmy-staging//',
'/timmy-staging;Secure',
'/timmy-staging"quoted',
"/timmy-staging'quoted",
'/timmy staging',
'/timmy\nstaging',
];
for (const value of invalid) {
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: value },
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk; });
const exitCode = await Promise.race([
new Promise(resolve => child.once('exit', resolve)),
new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, 800)),
]);
assert.notEqual(exitCode, 'timeout', `${JSON.stringify(value)} was accepted`);
assert.notEqual(exitCode, 0, `${JSON.stringify(value)} exited successfully`);
assert.match(stderr, /TIMMY_BASE_PATH/);
}
});
+98
View File
@@ -0,0 +1,98 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
const appUrl = new URL(process.env.TIMMY_STAGING_URL || 'http://127.0.0.1:4173/');
const expectedTag = process.env.TIMMY_EXPECT_RELEASE_TAG || 'daily-test';
const expectedCommit = process.env.TIMMY_EXPECT_RELEASE_COMMIT || 'c'.repeat(40);
const username = process.env.TIMMY_STAGING_USER || '';
const password = process.env.TIMMY_STAGING_PASSWORD || '';
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(appUrl.hostname);
if (!loopback && (!username || !password)) throw new Error('Live staging smoke requires TIMMY_STAGING_USER and TIMMY_STAGING_PASSWORD.');
if (!/^[0-9a-f]{40}$/.test(expectedCommit)) throw new Error('TIMMY_EXPECT_RELEASE_COMMIT must be a full lowercase commit.');
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 2,
serviceWorkers: 'block',
acceptDownloads: true,
...(username && password ? { httpCredentials: { username, password } } : {}),
});
const page = await context.newPage();
const browserErrors = [];
const secretFindings = [];
let agentChatRequests = 0;
page.on('console', message => { if (message.type() === 'error') browserErrors.push(message.text()); });
page.on('pageerror', error => browserErrors.push(error.message));
page.on('request', request => { if (new URL(request.url()).pathname.endsWith('/api/agent/chat')) agentChatRequests += 1; });
page.on('response', async response => {
const url = new URL(response.url());
if (!url.pathname.includes('/api/')) return;
for (const name of Object.keys(response.headers())) {
if (/authorization|set-cookie|x-api-key/i.test(name)) secretFindings.push(`sensitive response header ${name}`);
}
try {
const type = response.headers()['content-type'] || '';
if (/json|text/.test(type)) {
const body = (await response.text()).slice(0, 65_537);
if (body.length > 65_536) secretFindings.push('oversized API response');
if (/"(?:password|token|cookie|session|api[_-]?key|credential|environment|processEnv)"\s*:/i.test(body)) secretFindings.push(`secret-bearing response ${url.pathname}`);
}
} catch {}
});
const navigation = await page.goto(appUrl.toString(), { waitUntil: 'networkidle' });
assert.equal(navigation?.status(), 200, 'edge authentication and staging navigation must succeed');
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
const healthUrl = new URL(`${appUrl.pathname.replace(/\/$/, '')}/api/healthz`, appUrl);
const health = await page.evaluate(async url => {
const response = await fetch(url, { headers: { accept: 'application/json' } });
return { status: response.status, body: await response.json() };
}, healthUrl.toString());
assert.equal(health.status, 200);
assert.equal(health.body.ok, true);
assert.equal(health.body.release, expectedTag);
assert.equal(health.body.commit, expectedCommit);
assert.equal(health.body.agentEnabled, false, 'Phase 1 staging must keep Hermes off');
assert.match(await page.locator('.staging-label').innerText(), new RegExp(`Staging · ${expectedTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} · ${expectedCommit.slice(0, 12)}`));
assert.equal(await page.locator('main .btn-primary:visible').count(), 1, 'exactly one dominant photo action');
assert.equal(await page.locator('[data-log]:visible').count(), 1, 'manual fallback remains visible');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'home must not overflow');
await page.locator('[data-log]:visible').click();
await page.locator('[data-type="4"]').click();
await page.locator('#next').click();
await page.locator('#note').fill('synthetic staging smoke');
await page.locator('#next').click();
await page.locator('#save').click();
await page.getByRole('button', { name: 'Journal', exact: true }).click();
assert.match(await page.locator('main').innerText(), /synthetic staging smoke/);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'journal must not overflow');
await page.getByRole('button', { name: 'Timmy', exact: true }).click();
await page.locator('#chat-message').fill('I barfed');
await page.locator('#send-chat').click();
await page.waitForSelector('.bubble.timmy >> text=Pause and get medical help');
assert.equal(agentChatRequests, 0, 'urgent phrase must be intercepted before Hermes');
await page.getByRole('button', { name: 'Journal', exact: true }).click();
await page.locator('[data-view="privacy"]').click();
const downloadPromise = page.waitForEvent('download');
await page.locator('#export').click();
const download = await downloadPromise;
assert.equal(download.suggestedFilename(), 'timmy-ledger.json');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'privacy screen must not overflow');
await page.waitForTimeout(100);
assert.deepEqual(secretFindings, []);
assert.deepEqual(browserErrors, []);
await context.close();
console.log(`PASS staging smoke ${expectedTag} ${expectedCommit.slice(0, 12)} at ${appUrl}`);
} finally {
await browser.close();
}
+5
View File
@@ -2,6 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { probeVisionProvider, resolveVisionConfig } from '../src/vision-config.js';
test('documented boolean false disables staging vision fail closed', () => {
assert.equal(resolveVisionConfig({ TIMMY_VISION_ENABLED: 'false' }).enabled, false);
assert.equal(resolveVisionConfig({ TIMMY_VISION_ENABLED: '0' }).enabled, false);
});
test('selfhost profile defaults to a loopback OpenAI-compatible server and no remote processor', () => {
const config = resolveVisionConfig({ TIMMY_VISION_PROFILE: 'selfhost' });
assert.equal(config.profile, 'selfhost');