Compare commits
No commits in common. "main" and "timmy/47-sleek-hermes-agent" have entirely different histories.
main
...
timmy/47-s
|
|
@ -30,9 +30,7 @@ jobs:
|
|||
- name: Install browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Unit and security tests
|
||||
run: |
|
||||
npm test
|
||||
python3 tests/staging-deploy.test.py -v
|
||||
run: npm test
|
||||
- name: Mobile browser acceptance
|
||||
run: |
|
||||
npm start > /tmp/timmy-server.log 2>&1 &
|
||||
|
|
@ -50,13 +48,10 @@ 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
|
||||
node --check tests/staging.acceptance.mjs
|
||||
run: npm run check:syntax
|
||||
- name: Diff hygiene
|
||||
run: npm run check:diff
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -15,40 +15,28 @@ 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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Run Timmy in another terminal.
|
||||
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
|
||||
TIMMY_VISION_PROFILE=selfhost npm start
|
||||
# open http://localhost:4173
|
||||
|
||||
# When finished:
|
||||
scripts/bootstrap_selfhost_smolvlm.sh stop
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Run with a hosted provider
|
||||
|
||||
|
|
@ -132,8 +120,7 @@ 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/bootstrap_selfhost_smolvlm.sh pinned install/start/health/stop lifecycle
|
||||
├── scripts/run_selfhost_smolvlm.sh custom-path foreground launcher
|
||||
├── scripts/run_selfhost_smolvlm.sh
|
||||
├── scripts/ingest_training_photo.py
|
||||
├── localStorage saved ledger and attached photos
|
||||
└── llama.cpp / hosted API selected per server-side profile
|
||||
|
|
|
|||
56
app.js
56
app.js
|
|
@ -1,24 +1,7 @@
|
|||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, exportLedger, importLedger, photoQualityMessage, sanitizeEntry } from './src/domain.js';
|
||||
import { mergeVisualSuggestion } from './src/analysis.js';
|
||||
|
||||
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 STORE = 'timmy-ledger-v1';
|
||||
const app = document.querySelector('#app');
|
||||
let entries = loadEntries();
|
||||
let view = 'home';
|
||||
|
|
@ -41,8 +24,7 @@ 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) {
|
||||
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()}`;
|
||||
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()}`;
|
||||
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>`}
|
||||
|
|
@ -53,7 +35,7 @@ function bindGlobal(){
|
|||
}
|
||||
|
||||
function recentList(limit=5){
|
||||
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>`;
|
||||
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>`;
|
||||
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}
|
||||
|
|
@ -61,7 +43,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="${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>`);
|
||||
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>`);
|
||||
}
|
||||
|
||||
function calendar(){
|
||||
|
|
@ -87,13 +69,13 @@ function timmy(){
|
|||
requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight});
|
||||
}
|
||||
async function loadAgentStatus(){
|
||||
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'}}
|
||||
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'}}
|
||||
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(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()}
|
||||
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()}
|
||||
catch(error){chatError=error.message||'Could not connect.';timmy()}
|
||||
}
|
||||
function localChatReply(message){
|
||||
|
|
@ -102,14 +84,18 @@ function localChatReply(message){
|
|||
if(/food|eat|restaurant|taco/.test(lower))return 'A bowel journal cannot clear a food or restaurant. I can help you compare confirmed entries over time, not decide what is safe to eat.';
|
||||
return buildTimmySummary(entries);
|
||||
}
|
||||
function urgentChatReply(message){
|
||||
if(!/(blood|black (?:or |and )?dark[- ]?red stool|black stool|dark[- ]?red stool|severe|constant abdominal pain|vomit|fever|cannot pass gas|can['’]?t pass gas)/i.test(message))return '';
|
||||
return 'Pause and get medical help. Those symptoms can need prompt medical assessment. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.';
|
||||
}
|
||||
function ledgerForAgent(){return entries.slice(-20).map(({photoDataUrl,...entry})=>entry)}
|
||||
async function sendChat(event){
|
||||
event.preventDefault();if(chatBusy)return;const input=document.querySelector('#chat-message');const message=String(input?.value||'').trim();if(!message)return;
|
||||
chatMessages.push({role:'user',text:message});chatError='';input.value='';
|
||||
const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return}
|
||||
const urgent=urgentChatReply(message);if(urgent){chatMessages.push({role:'timmy',text:urgent});timmy();return}
|
||||
if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
|
||||
chatBusy=true;timmy();
|
||||
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})}
|
||||
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})}
|
||||
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()}
|
||||
}
|
||||
|
|
@ -120,11 +106,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);localStorage.removeItem(LEGACY_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);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(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
|
||||
try{const response=await fetch('/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)
|
||||
}
|
||||
|
|
@ -135,9 +121,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="${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==='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==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
|
||||
if(mode==='analyzing')return `<div class="analyzing"><img src="${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==='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==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||||
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
|
||||
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||||
|
|
@ -145,19 +131,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 Timmy’s 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()};
|
||||
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 file=document.querySelector('#ai-photo');if(file)file.onchange=handleAiPhoto;
|
||||
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(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.')}}
|
||||
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.')}}
|
||||
|
||||
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 class="progress-step-${step}"></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 style="width:${step*33.34}%"></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){
|
||||
|
|
@ -177,4 +163,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(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{});
|
||||
if('serviceWorker' in navigator)navigator.serviceWorker.register('/service-worker.js').catch(()=>{});
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 220 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 278 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 355 KiB |
|
|
@ -1,36 +0,0 @@
|
|||
# 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -4,13 +4,11 @@
|
|||
"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/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": "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: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",
|
||||
"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: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",
|
||||
"check:diff": "bash scripts/check_diff.sh",
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -109,7 +109,6 @@ 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)
|
||||
|
|
@ -134,9 +133,8 @@ 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", "tests/staging.acceptance.mjs"):
|
||||
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"):
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,433 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -50,15 +50,11 @@ await page.route('**/api/agent/status', route => route.fulfill({
|
|||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }),
|
||||
}));
|
||||
let hermesCalls = 0;
|
||||
await page.route('**/api/agent/chat', route => {
|
||||
hermesCalls += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/agent/chat', route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }),
|
||||
}));
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
|
@ -100,35 +96,13 @@ 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 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('The self-hosted vision route is ready', 1000);
|
||||
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await caption('Nothing uploads until explicit consent', 1100);
|
||||
await page.locator('#ai-consent').check();
|
||||
await tap('#analyze-photo', 450);
|
||||
|
|
@ -143,13 +117,8 @@ await caption('Hermes Agent connected — free-text, contextual, and server-side
|
|||
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 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');
|
||||
await caption('Urgent language is intercepted deterministically before Hermes', 1800);
|
||||
await sleep(400);
|
||||
await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1800);
|
||||
await sleep(500);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#demo-caption')?.remove();
|
||||
const outro = document.createElement('div');
|
||||
|
|
|
|||
50
server.mjs
50
server.mjs
|
|
@ -1,5 +1,4 @@
|
|||
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';
|
||||
|
|
@ -9,75 +8,46 @@ 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=>({'&':'&','<':'<','>':'>','"':'"'}[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=${appRoot}; Max-Age=86400${secure}`}
|
||||
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; 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(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'){
|
||||
if(url.pathname==='/api/vision-status'&&req.method==='GET'){
|
||||
const provider=await probeVisionProvider(visionConfig);
|
||||
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
|
||||
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
|
||||
}
|
||||
if(appPath==='/api/analyze'&&req.method==='POST'){
|
||||
if(url.pathname==='/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(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'){
|
||||
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'){
|
||||
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(appPath==='/api/agent/chat'&&req.method==='POST'){
|
||||
if(url.pathname==='/api/agent/chat'&&req.method==='POST'){
|
||||
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
|
||||
}
|
||||
if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
|
||||
if(url.pathname.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(appPath);
|
||||
const pathname=decodeURIComponent(url.pathname);
|
||||
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');
|
||||
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);
|
||||
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);
|
||||
}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,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
|
||||
}).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'}`));
|
||||
|
|
|
|||
|
|
@ -1,50 +1,5 @@
|
|||
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'))
|
||||
)),
|
||||
);
|
||||
});
|
||||
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'))))});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
|
||||
const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
|
||||
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|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],
|
||||
]);
|
||||
|
||||
export function bucketForBristolType(type) {
|
||||
const value = Number(type);
|
||||
|
|
@ -23,22 +14,11 @@ export function detectUrgentFlags(symptoms = {}) {
|
|||
urgent: flags.length > 0,
|
||||
flags,
|
||||
message: flags.length
|
||||
? URGENT_MESSAGE
|
||||
? 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.'
|
||||
: 'No urgent symptom was selected. This tracker is not a diagnosis; seek care whenever you are worried or symptoms persist.',
|
||||
};
|
||||
}
|
||||
|
||||
export function detectUrgentText(text = '') {
|
||||
const flags = URGENT_TEXT_PATTERNS.filter(([, pattern]) => pattern.test(String(text))).map(([key]) => key);
|
||||
return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' };
|
||||
}
|
||||
|
||||
export function hasUrgentLedgerContext(entries = []) {
|
||||
return Array.isArray(entries) && entries.some(entry => detectUrgentFlags(entry?.symptoms).urgent || detectUrgentText(entry?.note).urgent);
|
||||
}
|
||||
|
||||
export const urgentChatMessage = `Pause and get medical help. ${URGENT_MESSAGE}`;
|
||||
|
||||
export function buildTimmySummary(entries = []) {
|
||||
if (!entries.length) return 'No logs yet. Add one when you are ready and I’ll summarize the pattern—not diagnose it.';
|
||||
const counts = entries.reduce((acc, entry) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js';
|
||||
|
||||
const MAX_MESSAGE_CHARS = 4000;
|
||||
const MAX_LEDGER_ENTRIES = 20;
|
||||
|
|
@ -191,8 +190,6 @@ export function createHermesAgentService({
|
|||
if (!message) throw new AgentGatewayError(400, 'Write a message first.');
|
||||
if (message.length > MAX_MESSAGE_CHARS) throw new AgentGatewayError(413, 'Message is too long.');
|
||||
const ledger = sanitizeLedger(payload.ledger || []);
|
||||
const urgent = detectUrgentText(message).urgent || hasUrgentLedgerContext(ledger);
|
||||
if (urgent) return { reply: urgentChatMessage, connected: true, safetyOverride: true };
|
||||
const cutoff = now() - RATE_WINDOW_MS;
|
||||
session.requests = session.requests.filter(time => time > cutoff);
|
||||
if (session.requests.length >= config.maxRequestsPerMinute || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.');
|
||||
|
|
|
|||
|
|
@ -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: !['0', 'false'].includes(String(env.TIMMY_VISION_ENABLED || '').toLowerCase()),
|
||||
enabled: env.TIMMY_VISION_ENABLED !== '0',
|
||||
profile,
|
||||
processor: defaults.processor,
|
||||
baseUrl: env.TIMMY_VISION_BASE_URL || defaults.baseUrl,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ 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)}.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}
|
||||
.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}
|
||||
.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}}
|
||||
|
|
|
|||
|
|
@ -82,19 +82,6 @@ test('HTTP gateway binds browser auth to one opaque server-side Hermes conversat
|
|||
assert.match(second.reply, /Continuity confirmed/);
|
||||
assert.doesNotMatch(JSON.stringify(second), /fixture_session/);
|
||||
|
||||
response = await post('/api/agent/chat', { message: 'I have rectal bleeding', ledger: [] }, { cookie: browserCookie });
|
||||
assert.equal(response.status, 200);
|
||||
const urgentMessage = await response.json();
|
||||
assert.equal(urgentMessage.safetyOverride, true);
|
||||
assert.match(urgentMessage.reply, /medical help/i);
|
||||
assert.doesNotMatch(urgentMessage.reply, /bounded text-only|Continuity confirmed/i);
|
||||
|
||||
response = await post('/api/agent/chat', { message: 'Summarize this.', ledger: [{ bristolType: 4, color: 'brown', symptoms: { cannotPassGas: true } }] }, { cookie: browserCookie });
|
||||
assert.equal(response.status, 200);
|
||||
const urgentLedger = await response.json();
|
||||
assert.equal(urgentLedger.safetyOverride, true);
|
||||
assert.match(urgentLedger.reply, /medical help/i);
|
||||
|
||||
response = await post('/api/agent/chat', { message: 'Hijack', ledger: [], sessionId: 'attacker-session' }, { cookie: browserCookie });
|
||||
assert.equal(response.status, 400);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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');
|
||||
|
|
@ -20,7 +19,6 @@ 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/);
|
||||
|
|
@ -33,25 +31,6 @@ 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');
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ import {
|
|||
bucketForBristolType,
|
||||
buildTimmySummary,
|
||||
detectUrgentFlags,
|
||||
detectUrgentText,
|
||||
exportLedger,
|
||||
hasUrgentLedgerContext,
|
||||
photoQualityMessage,
|
||||
sanitizeEntry,
|
||||
} from '../src/domain.js';
|
||||
|
|
@ -43,50 +41,6 @@ test('does not invent reassurance when no urgent flags are reported', () => {
|
|||
assert.match(result.message, /not a diagnosis/i);
|
||||
});
|
||||
|
||||
test('detects common urgent symptom language without matching unrelated blood wording', () => {
|
||||
for (const message of [
|
||||
'I have rectal bleeding',
|
||||
'There is blood in my stool',
|
||||
'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', () => {
|
||||
assert.equal(hasUrgentLedgerContext([{ symptoms: { cannotPassGas: true }, note: '' }]), true);
|
||||
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'I have rectal bleeding' }]), true);
|
||||
assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'ordinary entry' }]), false);
|
||||
});
|
||||
|
||||
test('Timmy summary reports patterns without clearing food or diagnosing disease', () => {
|
||||
const entries = [
|
||||
{ bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' },
|
||||
|
|
|
|||
|
|
@ -118,37 +118,6 @@ test('chat keeps Hermes session IDs server-side, resumes continuity, and strips
|
|||
assert.doesNotMatch(JSON.stringify([first, second]), /hermes-private-session/);
|
||||
});
|
||||
|
||||
test('authoritative chat service intercepts urgent message and ledger symptoms before Hermes', async () => {
|
||||
const calls = [];
|
||||
const service = createHermesAgentService({
|
||||
config: configured(),
|
||||
randomToken: () => 'safety-cookie',
|
||||
runTurn: async input => { calls.push(input); return { reply: 'unsafe upstream reply', sessionId: 'unsafe-session' }; },
|
||||
});
|
||||
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 } }],
|
||||
} });
|
||||
const noteResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: {
|
||||
message: 'Summarize this entry',
|
||||
ledger: [{ bristolType: 4, color: 'brown', note: 'unable to pass gas', symptoms: {} }],
|
||||
} });
|
||||
|
||||
assert.equal(calls.length, 0);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
test('Hermes CLI parser accepts quiet output, strips control sequences, and rejects leaked metadata', () => {
|
||||
const parsed = parseHermesCliOutput('\u001b[32msession_id: 20260820_abcd\u001b[0m\nA concise answer.');
|
||||
assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' });
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
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');
|
||||
|
|
@ -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('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.locator('#ai-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(), /provider’s terms/i);
|
||||
|
|
|
|||
|
|
@ -10,24 +10,16 @@ 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 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, Hermes chat, and bootstrap syntax', async () => {
|
||||
test('release builder gates the sleek shell and Hermes chat browser path', 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"/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
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, []);
|
||||
});
|
||||
|
|
@ -3,16 +3,12 @@ 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' }) }));
|
||||
|
|
@ -22,18 +18,13 @@ 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(appUrl);
|
||||
await page.goto('http://127.0.0.1:4173');
|
||||
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();
|
||||
|
|
@ -46,75 +37,9 @@ await page.waitForSelector('.bubble.timmy >> text=mostly Type 4');
|
|||
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 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.');
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
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);
|
||||
});
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
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/);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -37,8 +37,8 @@ await page.locator('[data-view="timmy"]').last().click();
|
|||
await page.locator('#chat-message').fill('Can I eat Taco Bell?');
|
||||
await page.locator('#send-chat').click();
|
||||
const chatText = await page.locator('#chat').innerText();
|
||||
assert.match(chatText, /pause and get medical help/i);
|
||||
assert.doesNotMatch(chatText, /Taco Bell is safe|cannot clear a food or restaurant/i);
|
||||
assert.match(chatText, /cannot clear a food or restaurant/i);
|
||||
assert.doesNotMatch(chatText, /Taco Bell is safe/i);
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame');
|
||||
await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false });
|
||||
|
|
|
|||
|
|
@ -2,11 +2,6 @@ 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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user