Compare commits

..

14 Commits

Author SHA1 Message Date
5dac04b1e6 Merge pull request 'Harden mobile camera and gallery recovery' (#66) from timmy/11-mobile-capture-recovery into main
All checks were successful
Quality gates / quality (push) Successful in 1m56s
Merge nightly mobile capture recovery for issue #11 after exact-head review and green gates
2026-08-26 12:19:21 +00:00
87c7680aad ci: retry transient runner port collision
All checks were successful
Quality gates / quality (pull_request) Successful in 1m26s
2026-08-26 08:10:55 +00:00
463dff7f22 feat: harden mobile capture recovery (#11)
Some checks failed
Quality gates / quality (pull_request) Failing after 58s
2026-08-26 08:07:30 +00:00
47294a98aa Merge pull request 'Make staging runtime configuration compatible with strict CSP' (#59) from timmy/58-strict-csp-runtime-config into main
All checks were successful
Quality gates / quality (push) Successful in 1m56s
2026-08-21 15:04:43 +00:00
877b8a01f3 fix: make staging compatible with strict CSP
All checks were successful
Quality gates / quality (pull_request) Successful in 1m37s
2026-08-21 15:01:05 +00:00
067afaed4c Merge pull request 'Hardened atomic private-staging deployment tooling' (#56) from timmy/55-staging-deployment-tooling into main
All checks were successful
Quality gates / quality (push) Successful in 1m41s
2026-08-21 14:54:11 +00:00
f8f01c4b2e feat: add hardened atomic staging deployment
All checks were successful
Quality gates / quality (pull_request) Successful in 1m28s
2026-08-21 14:49:28 +00:00
fc763fb758 Merge pull request 'Private subpage staging foundation' (#54) from timmy/53-private-subpage-staging into main
All checks were successful
Quality gates / quality (push) Successful in 2m7s
2026-08-21 14:16:15 +00:00
7e11154302 feat: add private subpage staging slice
All checks were successful
Quality gates / quality (pull_request) Successful in 1m43s
2026-08-21 14:12:17 +00:00
11d1b364ab fix: gate bootstrap syntax in release build
All checks were successful
Quality gates / quality (push) Successful in 1m45s
2026-08-21 12:56:48 +00:00
8e3c2f8889 Merge pull request #52 from timmy/10-reproducible-smolvlm-bootstrap
Some checks failed
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
982fab273c feat: package pinned self-host vision bootstrap
All checks were successful
Quality gates / quality (pull_request) Successful in 1m30s
2026-08-21 08:15:32 +00:00
ca31e6d38b Merge pull request 'Hotfix: recognize past-tense vomiting in deterministic chat safety' (#51) from timmy/48-vomiting-phrase-hotfix into main
All checks were successful
Quality gates / quality (push) Successful in 1m26s
2026-08-20 17:17:33 +00:00
0f3104ebde fix: recognize past-tense vomiting in chat safety
All checks were successful
Quality gates / quality (pull_request) Successful in 1m21s
2026-08-20 17:14:20 +00:00
33 changed files with 2102 additions and 61 deletions

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 &
@ -48,10 +50,13 @@ jobs:
done
npm run test:ui
npm run test:photo
npm run test:mobile-capture
npm run test:sleek
- 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

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

48
app.js
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>${error?`<p class="capture-status" role="status">${esc(error)}</p>`:''}<div class="capture-choice-grid"><label class="photo-capture" for="camera-photo"><b>📷</b><strong>Take photo</strong><span>Use the rear camera</span><input id="camera-photo" type="file" accept="image/*" capture="environment"></label><label class="photo-capture" for="gallery-photo"><b>▧</b><strong>Choose from gallery</strong><span>JPEG, PNG, or WebP</span><input id="gallery-photo" type="file" accept="image/*"></label></div><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmys server does not save it. The compressed copy stays on Timmys self-hosted model server.':'Timmys server does not save it. Your configured AI provider processes it under that providers 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 couldnt 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>`;
@ -127,19 +145,19 @@ function photoFirstBody(mode,error=''){
function showPhotoFirst(mode='pick',error=''){
photoFirstMode=mode;
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmys suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
const file=document.querySelector('#ai-photo');if(file)file.onchange=handleAiPhoto;
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis;
document.querySelector('#retake-photo')?.addEventListener('click',()=>{photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')});
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)});
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(()=>{});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 311 KiB

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
}
}

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

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
docs/STAGING-RUNBOOK.md Normal file
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.

View File

@ -4,11 +4,13 @@
"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-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:mobile-capture": "node tests/mobile-capture.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: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/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.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"
},

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

View File

@ -109,6 +109,7 @@ def main() -> int:
raise SystemExit("Acceptance server did not become ready")
run(["npm", "run", "test:ui"], tree)
run(["npm", "run", "test:photo"], tree)
run(["npm", "run", "test:mobile-capture"], tree)
run(["npm", "run", "test:sleek"], tree)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
demo_env = dict(server_env)
@ -133,8 +134,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
scripts/deploy_staging.py Executable file
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())

View File

@ -100,13 +100,35 @@ async function tap(selector, after = 650) {
await sleep(after);
}
async function indicate(selector) {
const target = page.locator(selector).first();
const box = await target.boundingBox();
if (!box) throw new Error(`Missing demo target: ${selector}`);
await page.evaluate(({ x, y }) => {
document.querySelector('#demo-touch')?.remove();
const ring = document.createElement('div');
ring.id = 'demo-touch';
ring.style.left = `${x}px`;
ring.style.top = `${y}px`;
document.body.append(ring);
ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 });
setTimeout(() => ring.remove(), 550);
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
await sleep(650);
}
await caption(`TIMMY ${version} • FEATURE DEMO`, 1200);
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 page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
await indicate('label[for="camera-photo"]');
await page.locator('#camera-photo').dispatchEvent('cancel');
await page.getByText(/Camera or photo picker closed/i).waitFor();
await caption('Camera closed cleanly — gallery and manual logging are still available', 1600);
await indicate('label[for="gallery-photo"]');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('Nothing uploads until explicit consent', 1100);
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 450);
@ -122,7 +144,7 @@ await page.locator('#chat-message').fill('What pattern do you see?');
await tap('#send-chat', 500);
await page.getByText(/mostly Type 4/i).waitFor();
await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600);
await page.locator('#chat-message').fill('I have rectal bleeding');
await page.locator('#chat-message').fill('I barfed');
await tap('#send-chat', 450);
await page.getByText(/Pause and get medical help/i).waitFor();
if (hermesCalls !== 1) throw new Error('Urgent chat must be intercepted before Hermes');

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,75 @@ 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})}
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 Timmys 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'){
if(appPath==='/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(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'}`));

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'))
)),
);
});

View File

@ -4,7 +4,7 @@ const URGENT_TEXT_PATTERNS = Object.freeze([
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?) (?:black|dark[- ]?red))s?\b/i],
['severePain', /\b(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain\b/i],
['vomiting', /\b(?:vomit(?:ing|ed)?|throwing up)\b/i],
['vomiting', /\b(?:vomit(?:ing|ed|s)?|throw(?:ing|s)? up|threw up|thrown up|puk(?:e|ed|ing|es)|barf(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies|los(?:e|t|ing|es) (?:my|your|his|her|our|their|the) lunch|(?:i|we|you|he|she|they|someone) (?:(?:have|had|just|already|recently|am|are|was|were|kept) )?(?:hurl(?:s|ed|ing)?|spew(?:s|ed|ing)?)(?=\s*(?:[.!?]|$|again\b|twice\b|all night\b))|emesis)\b/i],
['fever', /\bfever(?:ish)?\b/i],
['cannotPassGas', /\b(?:cannot|can[']?t|cant|unable to|not able to) pass gas\b/i],
]);

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,

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)}
.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}
.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)}.capture-choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.capture-status{background:var(--red-soft);color:var(--red);border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.4}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:16px 10px;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}}

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');
@ -19,6 +20,7 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite'
assert.match(workflow, /npm test/);
assert.match(workflow, /npm run test:ui/);
assert.match(workflow, /npm run test:photo/);
assert.match(workflow, /npm run test:mobile-capture/);
assert.match(workflow, /npm run test:sleek/);
assert.match(workflow, /npm audit --audit-level=high/);
assert.match(workflow, /npm run check:syntax/);
@ -31,6 +33,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');

View File

@ -50,9 +50,35 @@ test('detects common urgent symptom language without matching unrelated blood wo
'My stool is black',
'I have severe stomach pain',
'I am throwing up and have a fever',
'I threw up',
'I puked twice',
'I am barfing',
'I barfed',
'I hurled',
'She hurls',
'I am upchucking',
'I spewed',
'She spews',
'I tossed my cookies',
'She tosses her cookies',
'He tossed his cookies',
'Someone is tossing their cookies',
'I lost my lunch',
'She loses her lunch',
'He lost his lunch',
'Someone is losing their lunch',
'I have emesis',
'I am unable to pass gas',
]) assert.equal(detectUrgentText(message).urgent, true, message);
assert.equal(detectUrgentText('My blood pressure was checked').urgent, false);
for (const nonVomiting of [
'She hurled the javelin across the field.',
'He hurls insults when angry.',
'They are hurling rocks at the wall.',
'He spewed hateful rhetoric.',
'The volcano spews ash.',
'The pipe is spewing water.',
]) assert.equal(detectUrgentText(nonVomiting).urgent, false, nonVomiting);
});
test('detects urgent flags or language in confirmed ledger context', () => {

View File

@ -128,6 +128,10 @@ test('authoritative chat service intercepts urgent message and ledger symptoms b
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
const messageResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } });
const pastTenseVomitingResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I threw up', ledger: [] } });
const slangVomitingResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I barfed', ledger: [] } });
const possessiveIdiomResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'She tosses her cookies', ledger: [] } });
const thirdPersonVomitingResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'She hurls', ledger: [] } });
const ledgerResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: {
message: 'What does my journal show?',
ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { cannotPassGas: true } }],
@ -138,7 +142,7 @@ test('authoritative chat service intercepts urgent message and ledger symptoms b
} });
assert.equal(calls.length, 0);
for (const result of [messageResult, ledgerResult, noteResult]) {
for (const result of [messageResult, pastTenseVomitingResult, slangVomitingResult, possessiveIdiomResult, thirdPersonVomitingResult, ledgerResult, noteResult]) {
assert.equal(result.safetyOverride, true);
assert.match(result.reply, /medical help/i);
assert.doesNotMatch(result.reply, /unsafe upstream/i);

View File

@ -0,0 +1,59 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
const browser = await chromium.launch({ headless: true });
const viewports = [
{ name: '390x844', width: 390, height: 844 },
{ name: 'iPhone 15 class', width: 393, height: 852 },
];
for (const viewport of viewports) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 2,
serviceWorkers: 'block',
});
const page = await context.newPage();
let analysisRequests = 0;
await page.route('**/api/vision-status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'synthetic-test-model' }),
}));
await page.route('**/api/analyze', route => {
analysisRequests += 1;
return route.abort();
});
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.locator('[data-scan]').click();
const camera = page.locator('#camera-photo');
const gallery = page.locator('#gallery-photo');
assert.equal(await camera.getAttribute('capture'), 'environment', `${viewport.name}: camera input uses the rear camera`);
assert.equal(await gallery.getAttribute('capture'), null, `${viewport.name}: gallery input does not force camera capture`);
assert.equal(await page.getByText('Take photo', { exact: true }).isVisible(), true);
assert.equal(await page.getByText('Choose from gallery', { exact: true }).isVisible(), true);
await camera.dispatchEvent('cancel');
assert.match(await page.locator('[role="status"]').innerText(), /camera.*closed|permission.*denied/i);
assert.equal(await page.getByText('Continue without AI', { exact: true }).isVisible(), true);
assert.equal(analysisRequests, 0, `${viewport.name}: cancellation never uploads`);
await page.locator('#gallery-photo').setInputFiles({
name: 'corrupt-synthetic.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from('not an image'),
});
assert.match(await page.locator('.scan-result').innerText(), /could not be read/i);
await page.getByText('Try another photo', { exact: true }).click();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
assert.equal(await page.locator('#retake-photo').isVisible(), true);
assert.equal(analysisRequests, 0, `${viewport.name}: corrupt and unconsented photos never upload`);
await context.close();
}
await browser.close();
console.log('PASS camera/gallery paths recover from cancellation at 390x844 and iPhone-class viewport without upload');

View File

@ -31,7 +31,7 @@ await page.locator('[data-scan]').click();
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.screenshot({ path: 'artifacts/selfhost-photo-first-mobile.png', fullPage: false });
assert.equal(await page.getByText('One photo. Two useful suggestions.').isVisible(), true);
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
assert.match(await page.locator('.consent-card').innerText(), /self-hosted model server/i);
assert.doesNotMatch(await page.locator('.consent-card').innerText(), /providers terms/i);

View File

@ -10,18 +10,24 @@ 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, /Camera closed cleanly — gallery and manual logging are still available/);
assert.match(demo, /#camera-photo.*dispatchEvent\('cancel'\)/s);
assert.match(demo, /#gallery-photo.*synthetic-type4\.jpg/s);
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/);
assert.match(demo, /I have rectal bleeding/);
assert.match(demo, /I barfed/);
assert.match(demo, /Urgent language is intercepted deterministically before Hermes/);
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, /"test:mobile-capture"/);
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"/);
});

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 });
});

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, []);
});

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();
@ -38,13 +47,74 @@ assert.equal(chatRequest.message, 'What pattern do you see?');
assert.ok(Array.isArray(chatRequest.ledger));
assert.equal(JSON.stringify(chatRequest).includes('photoDataUrl'), false, 'photos never enter chat context');
const previousRequest = chatRequest;
await page.locator('#chat-message').fill('I am unable to pass gas');
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(chatRequest, previousRequest, 'urgent language must be intercepted before the Hermes request');
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.');

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);
});

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()

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/);
}
});

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();
}

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');