diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bbdf2c..13a1ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - run: pnpm lint - run: pnpm typecheck - run: pnpm test:unit + - run: pnpm audit --audit-level high - run: pnpm format:check e2e: @@ -34,6 +35,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build - - run: pnpm --filter @iris/e2e exec playwright install --with-deps chromium + - run: pnpm --filter @syrin/iris-e2e --fail-if-no-match exec playwright install --with-deps chromium - name: Run e2e battery (boots api+demo+next-smoke, drives Iris headless) - run: pnpm --filter @iris/e2e run e2e:ci + run: pnpm --filter @syrin/iris-e2e --fail-if-no-match run e2e:ci diff --git a/apps/demo/.env.example b/apps/demo/.env.example index caeadc1..82c6fed 100644 --- a/apps/demo/.env.example +++ b/apps/demo/.env.example @@ -5,3 +5,6 @@ VITE_IRIS_WS_URL=ws://localhost:4400/iris # Pairing token — must match IRIS_TOKEN in the @syrin/iris-server .env (blank for localhost dev). VITE_IRIS_TOKEN= + +# Explicit opt-in for a deployed page or non-localhost bridge. Requires VITE_IRIS_TOKEN. +VITE_IRIS_ALLOW_NON_LOCALHOST=false diff --git a/apps/demo/package.json b/apps/demo/package.json index 30d755f..96c1796 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -19,12 +19,15 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@babel/core": "^7.29.7", + "@rolldown/plugin-babel": "^0.2.3", "@syrin/iris-babel-plugin": "workspace:*", + "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.0.2", "agentation": "^3.0.2", "agentation-mcp": "^1.2.0", - "vite": "^6.0.7" + "vite": "^8.0.16" } } diff --git a/apps/demo/src/iris-dev.ts b/apps/demo/src/iris-dev.ts index 0751b7c..95a3882 100644 --- a/apps/demo/src/iris-dev.ts +++ b/apps/demo/src/iris-dev.ts @@ -91,7 +91,20 @@ export function installIris(): void { const present = params.has('present'); const session = params.get('session') ?? SESSION_AUTO; const irisPort: number = typeof __IRIS_PORT__ !== 'undefined' ? __IRIS_PORT__ : 4400; - iris.connect({ session, present, url: `ws://localhost:${irisPort}/iris` }); + const token = import.meta.env.VITE_IRIS_TOKEN; + const configuredUrl = import.meta.env.VITE_IRIS_WS_URL; + const url = + typeof configuredUrl === 'string' && configuredUrl.length > 0 + ? configuredUrl + : `ws://localhost:${irisPort}/iris`; + const allowNonLocalhost = import.meta.env.VITE_IRIS_ALLOW_NON_LOCALHOST === 'true'; + iris.connect({ + session, + present, + url, + ...(allowNonLocalhost ? { allowNonLocalhost: true } : {}), + ...(typeof token === 'string' && token.length > 0 ? { token } : {}), + }); registerStore('app', () => useApp.getState()); registerCapabilities({ testids: TESTIDS, diff --git a/apps/demo/src/vite-env.d.ts b/apps/demo/src/vite-env.d.ts index 4487e9c..33a9fea 100644 --- a/apps/demo/src/vite-env.d.ts +++ b/apps/demo/src/vite-env.d.ts @@ -1,3 +1,13 @@ /// +interface ImportMetaEnv { + readonly VITE_IRIS_ALLOW_NON_LOCALHOST?: string; + readonly VITE_IRIS_TOKEN?: string; + readonly VITE_IRIS_WS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare const __IRIS_PORT__: number; diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index 4f7b5cd..f333287 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import babel from '@rolldown/plugin-babel'; import irisSource from '@syrin/iris-babel-plugin'; // The Iris showcase dashboard runs on a dedicated port (4310) so it never collides with other @@ -10,7 +11,7 @@ const IRIS_PORT = Number(process.env['IRIS_PORT'] ?? 4400); export default defineConfig({ // Stamp data-iris-source on host elements in dev so iris_inspect can map DOM -> file:line // (React 19 removed _debugSource). Dev-only; harmless in prod builds. - plugins: [react({ babel: { plugins: [irisSource] } })], + plugins: [babel({ plugins: [irisSource] }), react()], server: { port: 4310 }, define: { __IRIS_PORT__: IRIS_PORT }, }); diff --git a/apps/e2e/run-ci.sh b/apps/e2e/run-ci.sh index 91a9afa..d81e19e 100755 --- a/apps/e2e/run-ci.sh +++ b/apps/e2e/run-ci.sh @@ -23,6 +23,7 @@ for _ in $(seq 1 120); do sleep 2 done curl -s -o /dev/null http://localhost:8787/api/health || { echo "api never came up"; cat /tmp/e2e-api.log; exit 1; } +curl -s -o /dev/null http://localhost:4310 || { echo "demo never came up"; cat /tmp/e2e-demo.log; exit 1; } curl -s -o /dev/null http://localhost:3100 || { echo "next never came up"; cat /tmp/e2e-next.log; exit 1; } echo "==> running e2e battery" diff --git a/apps/e2e/specs/next-smoke-test.mjs b/apps/e2e/specs/next-smoke-test.mjs index 80d2881..15a8958 100644 --- a/apps/e2e/specs/next-smoke-test.mjs +++ b/apps/e2e/specs/next-smoke-test.mjs @@ -46,7 +46,7 @@ check('new task rendered after click', added.pass, added.failureReason ?? ''); console.log('\nTASK B — API call (Next route handler) + modal + no console errors'); const since = (await T('iris_act', { ref: await refOf('testid', 'ping-button'), action: 'click' })).since; const verdict = await T('iris_assert', { - timeout_ms: 4000, + timeout_ms: 10000, predicate: { kind: 'allOf', predicates: [ { kind: 'net', method: 'GET', urlContains: '/api/ping', status: 200, since }, { kind: 'element', query: { role: 'dialog', name: 'Server reply' }, state: 'visible' }, diff --git a/apps/e2e/specs/p3a-test.mjs b/apps/e2e/specs/p3a-test.mjs index f507b1f..78cf626 100644 --- a/apps/e2e/specs/p3a-test.mjs +++ b/apps/e2e/specs/p3a-test.mjs @@ -17,10 +17,10 @@ for(let i=0;i<200&&server.bridge.sessions.count()===0;i++) await sleep(50); const refOf=async(by,value)=>{for(let i=0;i<30;i++){const r=(await T('iris_query',{by,value})).elements?.[0]?.ref;if(r)return r;await sleep(100);}throw new Error('not found '+value);}; console.log('\n=== M8 Stage A: record → .iris/ flow → replay → drift (real browser) ==='); // record + save -await T('iris_record_start',{name:'addtask'}); +await T('iris_record_start',{recordingName:'addtask'}); await T('iris_act',{ref:await refOf('testid','add-task'),action:'click'}); -await T('iris_record_stop',{name:'addtask'}); -const saved=await T('iris_flow_save',{name:'addtask'}); +await T('iris_record_stop',{recordingName:'addtask'}); +const saved=await T('iris_flow_save',{flowName:'addtask'}); const flowFile=path.join(irisRoot,'flows','addtask.json'); chk('flow saved to .iris/flows/addtask.json on disk', nfs.existsSync(flowFile), flowFile); const raw=nfs.readFileSync(flowFile,'utf8'); @@ -28,11 +28,11 @@ chk('flow anchors on testid (no eXX refs leaked)', raw.includes('add-task') && ! const list=await T('iris_flow_list',{}); chk('iris_flow_list returns the saved flow', JSON.stringify(list).includes('addtask')); // replay happy path -const rep=await T('iris_flow_replay',{name:'addtask'}); +const rep=await T('iris_flow_replay',{flowName:'addtask'}); chk('iris_flow_replay re-resolves anchors + runs green', (rep.ok!==false)&&!rep.drift, JSON.stringify(rep).slice(0,90)); // drift: corrupt the testid, replay, expect legible drift with nearest match nfs.writeFileSync(flowFile, raw.replaceAll('add-task','add-tassk')); -const drift=await T('iris_flow_replay',{name:'addtask'}); +const drift=await T('iris_flow_replay',{flowName:'addtask'}); const ds=JSON.stringify(drift); chk('renamed testid → legible drift with a nearest-match', /drift/i.test(ds) && /add-task/.test(ds), ds.slice(0,140)); console.log(`\n${fail===0?'✅ M8 STAGE A VERIFIED':'❌ FAILED'} (${pass} passed, ${fail} failed)`); diff --git a/apps/e2e/specs/p3b-test.mjs b/apps/e2e/specs/p3b-test.mjs index 5a97bf0..412eb3a 100644 --- a/apps/e2e/specs/p3b-test.mjs +++ b/apps/e2e/specs/p3b-test.mjs @@ -13,19 +13,19 @@ await p.goto('http://localhost:3100/',{waitUntil:'networkidle'}); for(let i=0;i<200&&server.bridge.sessions.count()===0;i++) await sleep(50); const refOf=async(by,v)=>{for(let i=0;i<30;i++){const r=(await T('iris_query',{by,value:v})).elements?.[0]?.ref;if(r)return r;await sleep(100);}throw new Error('nf '+v);}; console.log('\n=== M8 Stage B: self-healing rebind (real browser) ==='); -await T('iris_record_start',{name:'ht'}); +await T('iris_record_start',{recordingName:'ht'}); await T('iris_act',{ref:await refOf('testid','add-task'),action:'click'}); -await T('iris_record_stop',{name:'ht'}); -await T('iris_flow_save',{name:'ht'}); +await T('iris_record_stop',{recordingName:'ht'}); +await T('iris_flow_save',{flowName:'ht'}); const file=path.join(irisRoot,'flows','ht.json'); // corrupt the testid nfs.writeFileSync(file, nfs.readFileSync(file,'utf8').replaceAll('add-task','add-tassk')); const bytesBefore=nfs.readFileSync(file,'utf8'); -const proposeOnly=await T('iris_flow_heal',{name:'ht',apply:false}); +const proposeOnly=await T('iris_flow_heal',{flowName:'ht',apply:false}); chk('heal(apply:false) proposes a rebind but does NOT write', /add-task/.test(JSON.stringify(proposeOnly)) && nfs.readFileSync(file,'utf8')===bytesBefore, JSON.stringify(proposeOnly).slice(0,120)); -const applied=await T('iris_flow_heal',{name:'ht',apply:true}); +const applied=await T('iris_flow_heal',{flowName:'ht',apply:true}); chk('heal(apply:true) rewrites the anchor back to add-task', nfs.readFileSync(file,'utf8').includes('add-task') && applied.applied===true, JSON.stringify(applied).slice(0,110)); -const rep=await T('iris_flow_replay',{name:'ht'}); +const rep=await T('iris_flow_replay',{flowName:'ht'}); chk('replay is green again after self-heal', rep.status==='ok'||rep.ok!==false&&!rep.drift, JSON.stringify(rep).slice(0,90)); console.log(`\n${fail===0?'✅ M8 STAGE B SELF-HEAL VERIFIED':'❌ FAILED'} (${pass} passed, ${fail} failed)`); await b.close(); await server.close(); nfs.rmSync(path.dirname(irisRoot),{recursive:true,force:true}); process.exit(fail===0?0:1); diff --git a/apps/e2e/specs/project-history-test.mjs b/apps/e2e/specs/project-history-test.mjs index c2d1bba..337d93e 100644 --- a/apps/e2e/specs/project-history-test.mjs +++ b/apps/e2e/specs/project-history-test.mjs @@ -22,14 +22,14 @@ const refOf=async(by,value)=>{for(let i=0;i<30;i++){const r=(await T('iris_query console.log('\n=== 0.3.7 RUNHISTORY: replay → .iris/project.json → iris_project diff (real browser) ==='); // Record + save a one-step flow. -await T('iris_record_start',{name:'addtask'}); +await T('iris_record_start',{recordingName:'addtask'}); await T('iris_act',{ref:await refOf('testid','add-task'),action:'click'}); -await T('iris_record_stop',{name:'addtask'}); -await T('iris_flow_save',{name:'addtask'}); +await T('iris_record_stop',{recordingName:'addtask'}); +await T('iris_flow_save',{flowName:'addtask'}); // Replay twice — each replay should auto-record a run. -await T('iris_flow_replay',{name:'addtask'}); -await T('iris_flow_replay',{name:'addtask'}); +await T('iris_flow_replay',{flowName:'addtask'}); +await T('iris_flow_replay',{flowName:'addtask'}); // 1) project.json exists on disk and holds flow_replay records. const projFile=path.join(irisRoot,'project.json'); @@ -41,13 +41,13 @@ chk('each run carries status + driftSteps evidence + at', onDisk.runs.every(r=>r // 2) iris_project { name } returns scoped history + lastRun + diff-vs-last. const proj=await T('iris_project',{name:'addtask'}); chk('iris_project returns scoped runs', Array.isArray(proj.runs)&&proj.runs.length===2, `runs=${proj.runs?.length}`); -chk('iris_project returns lastRun', proj.lastRun&&proj.lastRun.name==='addtask', JSON.stringify(proj.lastRun).slice(0,80)); -chk('iris_project returns a diff-vs-last block', proj.diff&&typeof proj.diff.regressed==='boolean', JSON.stringify(proj.diff).slice(0,100)); +chk('iris_project returns lastRun', proj.lastRun&&proj.lastRun.name==='addtask', JSON.stringify(proj.lastRun)?.slice(0,80)); +chk('iris_project returns a diff-vs-last block', proj.diff&&typeof proj.diff.regressed==='boolean', JSON.stringify(proj.diff)?.slice(0,100)); // 3) iris_run_record appends a manual run that lastRun then sees. await T('iris_run_record',{name:'addtask',status:'pass',summary:'manual smoke'}); const after=await T('iris_project',{name:'addtask'}); -chk('iris_run_record appends a manual run', after.lastRun?.kind==='manual'&&after.lastRun?.summary==='manual smoke', JSON.stringify(after.lastRun).slice(0,100)); +chk('iris_run_record appends a manual run', after.lastRun?.kind==='manual'&&after.lastRun?.summary==='manual smoke', JSON.stringify(after.lastRun)?.slice(0,100)); console.log(`\n${fail===0?'✅ RUNHISTORY VERIFIED':'❌ FAILED'} (${pass} passed, ${fail} failed)`); await b.close(); await server.close(); nfs.rmSync(path.dirname(irisRoot),{recursive:true,force:true}); process.exit(fail===0?0:1); diff --git a/apps/e2e/specs/real-world-tests.mjs b/apps/e2e/specs/real-world-tests.mjs index 3995ecd..423ba56 100644 --- a/apps/e2e/specs/real-world-tests.mjs +++ b/apps/e2e/specs/real-world-tests.mjs @@ -26,7 +26,7 @@ const refOf = async (by, value) => { for (let i = 0; i < 40; i++) { const r = (a const b = await chromium.launch({ headless: true }); const p = await b.newPage(); -await p.goto('http://localhost:4310/', { waitUntil: 'networkidle' }); +await p.goto('http://localhost:4310/?session=demo', { waitUntil: 'networkidle' }); for (let i = 0; i < 200 && server.bridge.sessions.count() === 0; i++) await sleep(50); console.log('\n=== Iris × showcase dashboard (:4310) ==='); diff --git a/apps/e2e/specs/spa-nav-realinput-test.mjs b/apps/e2e/specs/spa-nav-realinput-test.mjs index d154f8d..d094ec5 100644 --- a/apps/e2e/specs/spa-nav-realinput-test.mjs +++ b/apps/e2e/specs/spa-nav-realinput-test.mjs @@ -19,7 +19,7 @@ const refOf=async()=>{for(let i=0;i<30;i++){const r=(await T('iris_query',{by:'t console.log('\n=== bug #1: real input survives SPA navigation (real Chromium + CDP) ==='); // baseline on "/" let ref=await refOf(); -const a1=await T('iris_act',{ref,action:'click'}); +const a1=await T('iris_act',{ref,action:'click',args:{native:true}}); chk('pre-nav: iris_act is REAL', a1.inputMode==='real', `inputMode=${a1.inputMode} url=${sess.url}`); chk('pre-nav: realInputAvailable true', (await provider.isAvailableFor(sess.url))===true); // CLIENT-SIDE NAV (pushState) — no full reload; SDK stays connected and emits route.change @@ -27,10 +27,12 @@ await page.evaluate(()=>history.pushState({},'','/workspace?script=42')); for(let i=0;i<40&&!/\/workspace\?script=42/.test(sess.url);i++) await sleep(50); // wait for route.change → server chk('after pushState: session.url tracks the SPA route (THE FIX)', /\/workspace\?script=42$/.test(sess.url), `url=${sess.url}`); chk('after pushState: page.url matches session.url', page.url()===sess.url, `cdp=${page.url()}`); -chk('after pushState: realInputAvailable STILL true', (await provider.isAvailableFor(sess.url))===true); +let available=false; +for(let i=0;i<40&&!available;i++){available=await provider.isAvailableFor(sess.url);if(!available)await sleep(50);} +chk('after pushState: realInputAvailable STILL true', available); // real input must STILL engage post-nav (the button is still mounted; pushState didn't re-render) ref=await refOf(); -const a2=await T('iris_act',{ref,action:'click'}); +const a2=await T('iris_act',{ref,action:'click',args:{native:true}}); chk('after pushState: iris_act is STILL REAL (was synthetic before the fix)', a2.inputMode==='real', `inputMode=${a2.inputMode}`); console.log(`\n${fail===0?'✅ BUG #1 FIXED':'❌ STILL BROKEN'} (${pass} passed, ${fail} failed)`); await browser.close(); await server.close(); process.exit(fail===0?0:1); diff --git a/apps/next-smoke/.env.example b/apps/next-smoke/.env.example new file mode 100644 index 0000000..b0d881e --- /dev/null +++ b/apps/next-smoke/.env.example @@ -0,0 +1,2 @@ +# Optional browser/bridge pairing token. Must match IRIS_TOKEN. +NEXT_PUBLIC_IRIS_TOKEN= diff --git a/apps/next-smoke/app/iris-dev.tsx b/apps/next-smoke/app/iris-dev.tsx index 19de8ea..13367b1 100644 --- a/apps/next-smoke/app/iris-dev.tsx +++ b/apps/next-smoke/app/iris-dev.tsx @@ -19,7 +19,12 @@ export function IrisDev() { testids: ['ping-button', 'add-task', 'edit-field', 'show-toast'], signals: ['field:committed'], }); - iris.connect({ session: 'next-smoke', present: true }); + const token = process.env['NEXT_PUBLIC_IRIS_TOKEN']; + iris.connect({ + session: 'next-smoke', + present: true, + ...(typeof token === 'string' && token.length > 0 ? { token } : {}), + }); })(); }, []); return null; diff --git a/docs/flows.md b/docs/flows.md index c81ef7a..c58b084 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -99,7 +99,8 @@ iris_flow_replay({ flowName: "create-task" }) // re-resolve each anchor against - `drift` — an anchor missed (a testid was renamed, or a signal never fired). The result is **legible**: `{ step, anchor, drift: { reasonKind: "testid_not_found", nearest: "send-message" } }` — never a blind failure. (This is the "whose fault is it" principle.) -- `error` — the flow file is missing/invalid; no steps ran. +- `error` — the flow file is missing/invalid, or a resolved action failed. Runtime failures include + the failed step and a top-level error envelope. A testid-_preserving_ refactor (you moved markup but kept the testids) still replays green. diff --git a/package.json b/package.json index 5a2917d..2d7b346 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "description": "Iris — eyes for coding agents. Observe and verify a running web app over MCP, no screenshots.", "packageManager": "pnpm@10.33.2", "engines": { - "node": ">=22" + "node": ">=22.12" }, "scripts": { "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", "typecheck": "turbo run typecheck", - "test": "turbo run test", + "test": "turbo run test:unit", "test:unit": "turbo run test:unit", "format": "prettier --write .", "format:check": "prettier --check .", @@ -20,6 +20,10 @@ "publish:dry-run": "pnpm build && pnpm -r publish --access public --no-git-checks --dry-run" }, "pnpm": { + "overrides": { + "esbuild@>=0.18.0 <0.28.1": "0.28.1", + "postcss@<8.5.10": "8.5.15" + }, "onlyBuiltDependencies": [ "esbuild" ] @@ -27,7 +31,7 @@ "devDependencies": { "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^3.2.6", "eslint": "^9.18.0", "eslint-plugin-react-hooks": "^5.1.0", "jsdom": "^29.1.1", @@ -36,7 +40,7 @@ "turbo": "^2.3.3", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", - "vitest": "^3.0.0", + "vitest": "^3.2.6", "ws": "^8.21.0" } } diff --git a/packages/babel-plugin/package.json b/packages/babel-plugin/package.json index 3627e46..4efed57 100644 --- a/packages/babel-plugin/package.json +++ b/packages/babel-plugin/package.json @@ -37,7 +37,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "peerDependencies": { diff --git a/packages/browser/package.json b/packages/browser/package.json index 553435b..f31c4c6 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -15,7 +15,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "dependencies": { diff --git a/packages/browser/src/actions/actions.effect.test.ts b/packages/browser/src/actions/actions.effect.test.ts index 359f102..cc5e13c 100644 --- a/packages/browser/src/actions/actions.effect.test.ts +++ b/packages/browser/src/actions/actions.effect.test.ts @@ -235,13 +235,13 @@ describe('action result: hover enter/leave warning (F3)', () => { describe('action result: testid normalization (G6)', () => { it('includes data-testid of the resolved element', async () => { document.body.innerHTML = ''; - const r = await executeAction(refOf('button'), 'click'); + const r = await executeAction(refOf('button'), 'click', { confirmDangerous: true }); expect(r.testid).toBe('pay-btn'); }); it('omits testid when the element has none', async () => { document.body.innerHTML = ''; - const r = await executeAction(refOf('button'), 'click'); + const r = await executeAction(refOf('button'), 'click', { confirmDangerous: true }); expect(r.testid).toBeUndefined(); }); diff --git a/packages/browser/src/actions/actions.interactions.test.ts b/packages/browser/src/actions/actions.interactions.test.ts index 6121ac1..7efa721 100644 --- a/packages/browser/src/actions/actions.interactions.test.ts +++ b/packages/browser/src/actions/actions.interactions.test.ts @@ -73,6 +73,36 @@ describe('webmcp passthrough', () => { expect(callTool).toHaveBeenCalledWith('search', { q: 'x' }); expect(result).toEqual({ called: 'search' }); }); + + it('blocks dangerous tools without explicit confirmation', async () => { + const callTool = vi.fn(() => Promise.resolve({ ok: true })); + (navigator as unknown as Record)['modelContext'] = { callTool }; + const reg = createCommandRegistry(); + const handler = reg.get('act'); + if (handler === undefined) throw new Error('no act handler'); + await expect( + handler({ action: 'webmcp', args: { tool: 'delete_account', params: {} } }), + ).rejects.toThrow(/confirmDangerous/); + await handler({ + action: 'webmcp', + args: { tool: 'delete_account', params: {}, confirmDangerous: true }, + }); + expect(callTool).toHaveBeenCalledOnce(); + }); +}); + +describe('dangerous action confirmation', () => { + it('blocks a destructive click until explicitly confirmed', async () => { + document.body.innerHTML = ''; + const button = document.querySelector('button') as HTMLButtonElement; + const ref = refs.refFor(button); + const clicked = vi.fn(); + button.addEventListener('click', clicked); + await expect(executeAction(ref, 'click')).rejects.toThrow(/confirmDangerous/); + expect(clicked).not.toHaveBeenCalled(); + await executeAction(ref, 'click', { confirmDangerous: true }); + expect(clicked).toHaveBeenCalledOnce(); + }); }); describe('dev overlay', () => { diff --git a/packages/browser/src/actions/actions.ts b/packages/browser/src/actions/actions.ts index 9a67d2d..770d7fb 100644 --- a/packages/browser/src/actions/actions.ts +++ b/packages/browser/src/actions/actions.ts @@ -1,6 +1,13 @@ -import { ActionType, ActionWarning, ElementState, SettleReason } from '@syrin/iris-protocol'; +import { + ActionType, + ActionWarning, + DANGEROUS_ACTION_CONFIRM_ARG, + ElementState, + isDangerousActionText, + SettleReason, +} from '@syrin/iris-protocol'; import { refs } from '../dom/refs.js'; -import { isVisible, getStates } from '../dom/a11y.js'; +import { getAccessibleName, isVisible, getStates } from '../dom/a11y.js'; import { elementHasHoverHandlers } from '../registry/adapters.js'; import { nativeSetTimeout, nativeFrame, settle } from '../timers/native-timers.js'; @@ -114,6 +121,47 @@ const isFillLike = (action: string): boolean => FILL_LIKE.has(action); /** Actions that resolve to a point and so benefit from off-viewport scroll + occlusion hit-test. */ const CLICK_LIKE = new Set([ActionType.CLICK, ActionType.DBLCLICK]); +function dangerousActionContext(el: HTMLElement): string { + const form = el.closest('form'); + return [ + getAccessibleName(el), + el.textContent ?? '', + el.getAttribute('value') ?? '', + el.getAttribute('title') ?? '', + el.getAttribute('aria-label') ?? '', + el.getAttribute('href') ?? '', + form?.getAttribute('action') ?? '', + form?.textContent ?? '', + ].join(' '); +} + +export function requiresDangerousConfirmation(text: string): boolean { + return isDangerousActionText(text); +} + +function assertActionAllowed(el: HTMLElement, action: string, args: Record): void { + const canTrigger = + action === ActionType.CLICK || + action === ActionType.DBLCLICK || + action === ActionType.DRAG || + action === ActionType.SUBMIT || + (action === ActionType.PRESS && asString(args['key'], 'Enter') === 'Enter'); + const dragTarget = action === ActionType.DRAG ? refs.resolve(asString(args['toRef'])) : null; + const context = + dragTarget instanceof HTMLElement + ? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}` + : dangerousActionContext(el); + if ( + canTrigger && + requiresDangerousConfirmation(context) && + args[DANGEROUS_ACTION_CONFIRM_ARG] !== true + ) { + throw new Error( + `potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`, + ); + } +} + /** Best-effort click-point geometry. All-false default when there is nothing measurable to test. */ interface ClickGeometry { occluded: boolean; @@ -345,6 +393,7 @@ export async function executeAction( args: Record = {}, ): Promise { const el = requireElement(ref); + assertActionAllowed(el, action, args); const visible = isVisible(el); const enabled = enabledOf(el); const prevFocus = activeRef(el); @@ -488,7 +537,13 @@ async function dragElement( export async function dispatchWebMcp( tool: string, params: Record, + confirmDangerous = false, ): Promise { + if (requiresDangerousConfirmation(tool) && !confirmDangerous) { + throw new Error( + `potentially destructive WebMCP tool blocked; retry with ${DANGEROUS_ACTION_CONFIRM_ARG}=true`, + ); + } const mc = ( navigator as unknown as { modelContext?: { callTool?: (n: string, p: unknown) => unknown } } ).modelContext; diff --git a/packages/browser/src/commands/commands.test.ts b/packages/browser/src/commands/commands.test.ts index 3043332..9ba5a1f 100644 --- a/packages/browser/src/commands/commands.test.ts +++ b/packages/browser/src/commands/commands.test.ts @@ -5,7 +5,7 @@ import { type ComponentStateResult, type MatchResult, } from '@syrin/iris-protocol'; -import { createCommandRegistry } from './commands.js'; +import { createCommandRegistry, resolveNavigationUrl } from './commands.js'; import { refs } from '../dom/refs.js'; import { registerStore, unregisterStore } from '../registry/stores.js'; import { registerAdapter } from '../registry/adapters.js'; @@ -26,6 +26,19 @@ function run(name: string, args: Record = {}): unknown { } describe('command registry (driven by the bridge)', () => { + it('allows relative/http(s) navigation and rejects executable protocols', () => { + expect(resolveNavigationUrl('/next', 'https://app.example/current')).toBe( + 'https://app.example/next', + ); + expect(resolveNavigationUrl('https://safe.example/path', 'https://app.example/')).toBe( + 'https://safe.example/path', + ); + expect(resolveNavigationUrl('javascript:globalThis.pwned=true', 'https://app.example/')).toBe( + null, + ); + expect(resolveNavigationUrl('data:text/html,boom', 'https://app.example/')).toBe(null); + }); + it('SNAPSHOT returns a tree with status', () => { document.body.innerHTML = ''; const result = run(IrisCommand.SNAPSHOT, {}) as { tree: string; status: { route: string } }; diff --git a/packages/browser/src/commands/commands.ts b/packages/browser/src/commands/commands.ts index 8ce39f8..3c8f5cf 100644 --- a/packages/browser/src/commands/commands.ts +++ b/packages/browser/src/commands/commands.ts @@ -1,9 +1,11 @@ import { ActionType, ComponentStateReason, + DANGEROUS_ACTION_CONFIRM_ARG, ElementQuerySchema, IrisCommand, SnapshotMode, + TRANSPORT_LIMITS, type ComponentStateResult, type ElementQuery, type ElementState, @@ -63,6 +65,15 @@ function inspect(ref: string): unknown { return { ...describe(el), tag: el.tagName.toLowerCase(), + href: el.getAttribute('href') ?? undefined, + formAction: + el instanceof HTMLButtonElement || el instanceof HTMLInputElement + ? (el.form?.getAttribute('action') ?? undefined) + : undefined, + formText: + el instanceof HTMLButtonElement || el instanceof HTMLInputElement + ? (el.form?.textContent ?? undefined) + : undefined, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, styles, component, @@ -123,6 +134,16 @@ function listAnimations(): unknown { return { animations }; } +export function resolveNavigationUrl(rawUrl: string, baseUrl: string): string | null { + if (rawUrl.length === 0 || rawUrl.length > TRANSPORT_LIMITS.MAX_URL_LENGTH) return null; + try { + const url = new URL(rawUrl, baseUrl); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null; + } catch { + return null; + } +} + /** Map browser command names to handlers. Used by the transport on each COMMAND. */ export function createCommandRegistry(): Map { const reg = new Map(); @@ -143,7 +164,11 @@ export function createCommandRegistry(): Map { const action = str(args['action']) ?? ''; if (action === ActionType.WEBMCP) { const inner = record(args['args']); - return dispatchWebMcp(str(inner['tool']) ?? '', record(inner['params'])); + return dispatchWebMcp( + str(inner['tool']) ?? '', + record(inner['params']), + inner[DANGEROUS_ACTION_CONFIRM_ARG] === true, + ); } return executeAction(str(args['ref']) ?? '', action, record(args['args'])); }); @@ -174,8 +199,10 @@ export function createCommandRegistry(): Map { ); }); reg.set(IrisCommand.NAVIGATE, (args) => { - const url = str(args['url']); - if (url === undefined || url.length === 0) return { ok: false, reason: 'url required' }; + const rawUrl = str(args['url']); + if (rawUrl === undefined || rawUrl.length === 0) return { ok: false, reason: 'url required' }; + const url = resolveNavigationUrl(rawUrl, window.location.href); + if (url === null) return { ok: false, reason: 'only http(s) navigation is allowed' }; window.location.assign(url); return { ok: true, url }; }); diff --git a/packages/browser/src/dom/a11y.security.test.ts b/packages/browser/src/dom/a11y.security.test.ts new file mode 100644 index 0000000..202392f --- /dev/null +++ b/packages/browser/src/dom/a11y.security.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { REDACTED_VALUE } from '@syrin/iris-protocol'; +import { buildSnapshot } from './snapshot.js'; +import { describe as describeElement, getValue } from './a11y.js'; + +describe('sensitive form values', () => { + it('redacts password values from descriptors and snapshots', () => { + document.body.innerHTML = ''; + const input = document.querySelector('input') as HTMLInputElement; + expect(getValue(input)).toBe(REDACTED_VALUE); + expect(describeElement(input).value).toBe(REDACTED_VALUE); + const snapshot = buildSnapshot(); + expect(snapshot.tree).toContain(`[value="${REDACTED_VALUE}"]`); + expect(snapshot.tree).not.toContain('supersecret'); + }); + + it('redacts values identified by autocomplete and field names', () => { + document.body.innerHTML = ` + + + `; + const fields = [...document.querySelectorAll('input, textarea')]; + expect(fields.map(getValue)).toEqual([REDACTED_VALUE, REDACTED_VALUE]); + }); +}); diff --git a/packages/browser/src/dom/a11y.ts b/packages/browser/src/dom/a11y.ts index ae8c42d..995114e 100644 --- a/packages/browser/src/dom/a11y.ts +++ b/packages/browser/src/dom/a11y.ts @@ -1,5 +1,6 @@ -import { ElementState, type ElementDescriptor } from '@syrin/iris-protocol'; +import { ElementState, REDACTED_VALUE, type ElementDescriptor } from '@syrin/iris-protocol'; import { refs } from './refs.js'; +import { isSensitiveKey } from '../security/serialization.js'; /** Roles whose accessible name comes from their text content. */ const NAME_FROM_CONTENT = new Set([ @@ -183,6 +184,22 @@ export function getValue(el: Element): string | undefined { el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement ) { + const autocomplete = el.getAttribute('autocomplete') ?? ''; + const identifiers = [ + el.getAttribute('name') ?? '', + el.id, + el.getAttribute('data-testid') ?? '', + el.getAttribute('aria-label') ?? '', + ]; + const sensitiveAutocomplete = + /current-password|new-password|cc-number|cc-csc|one-time-code/i.test(autocomplete); + if ( + (el instanceof HTMLInputElement && el.type.toLowerCase() === 'password') || + sensitiveAutocomplete || + identifiers.some(isSensitiveKey) + ) { + return REDACTED_VALUE; + } return el.value; } const valueNow = el.getAttribute('aria-valuenow'); diff --git a/packages/browser/src/iris.session.test.ts b/packages/browser/src/iris.session.test.ts index 26c74a4..6626c03 100644 --- a/packages/browser/src/iris.session.test.ts +++ b/packages/browser/src/iris.session.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { SESSION_AUTO } from '@syrin/iris-protocol'; -import { resolveSessionLabel } from './iris.js'; +import { SESSION_AUTO, TRANSPORT_LIMITS } from '@syrin/iris-protocol'; +import { connectionPolicy, resolveSessionLabel } from './iris.js'; describe('resolveSessionLabel', () => { const gen = (): string => 'unique-123'; @@ -17,3 +17,56 @@ describe('resolveSessionLabel', () => { expect(resolveSessionLabel('alianpost', gen)).toBe('alianpost'); }); }); + +describe('connectionPolicy', () => { + it('allows local pages and local bridges without a token', () => { + expect(connectionPolicy('localhost', 'ws://127.0.0.1:4400/iris', false, undefined)).toEqual({ + allowed: true, + }); + }); + + it('requires explicit opt-in and a token outside localhost', () => { + expect(connectionPolicy('app.example', 'wss://bridge.example/iris', false, 'token')).toEqual({ + allowed: false, + reason: 'Iris is disabled outside localhost unless allowNonLocalhost is explicitly enabled', + }); + expect(connectionPolicy('app.example', 'wss://bridge.example/iris', true, undefined)).toEqual({ + allowed: false, + reason: 'a pairing token is required outside localhost', + }); + expect(connectionPolicy('app.example', 'wss://bridge.example/iris', true, 'token')).toEqual({ + allowed: true, + }); + }); + + it('requires encrypted transport for a non-local bridge', () => { + expect(connectionPolicy('localhost', 'ws://bridge.example/iris', true, 'token')).toEqual({ + allowed: false, + reason: 'a non-local Iris bridge must use wss://', + }); + }); + + it('does not treat loopback-lookalike DNS names as localhost', () => { + expect( + connectionPolicy('127.evil.example', 'ws://127.0.0.1:4400/iris', false, undefined), + ).toEqual({ + allowed: false, + reason: 'Iris is disabled outside localhost unless allowNonLocalhost is explicitly enabled', + }); + }); + + it('rejects tokens beyond the wire-schema limit before connecting', () => { + expect( + connectionPolicy( + 'localhost', + 'ws://127.0.0.1:4400/iris', + false, + 'x'.repeat(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH + 1), + ).allowed, + ).toBe(false); + }); + + it('rejects non-WebSocket bridge URLs', () => { + expect(connectionPolicy('localhost', 'javascript:alert(1)', true, 'token').allowed).toBe(false); + }); +}); diff --git a/packages/browser/src/iris.ts b/packages/browser/src/iris.ts index 85a0489..fb51059 100644 --- a/packages/browser/src/iris.ts +++ b/packages/browser/src/iris.ts @@ -8,6 +8,8 @@ import { PresenterMode, SESSION_AUTO, SessionState, + TRANSPORT_LIMITS, + isLoopbackHostname, type CommandMessage, type HelloMessage, type IrisEvent, @@ -47,6 +49,10 @@ export interface IrisConnectOptions { url?: string; /** Human-friendly session label so the agent can target the right tab. */ session?: string; + /** Browser/bridge pairing token. Required when either endpoint is non-localhost. */ + token?: string; + /** Explicitly allow Iris on a non-localhost page or bridge. Requires token. */ + allowNonLocalhost?: boolean; /** Show a small in-page status chip (connection + event count). */ overlay?: boolean; /** Presenter mode: glow border, animated cursor, click/hover effects, narration HUD. */ @@ -73,6 +79,45 @@ export interface IrisConnectOptions { idleEndMs?: number; } +export function connectionPolicy( + pageHostname: string, + bridgeUrl: string, + allowNonLocalhost: boolean, + token: string | undefined, +): { allowed: boolean; reason?: string } { + let bridge: URL; + try { + bridge = new URL(bridgeUrl); + } catch { + return { allowed: false, reason: 'invalid Iris bridge URL' }; + } + if (bridge.protocol !== 'ws:' && bridge.protocol !== 'wss:') { + return { allowed: false, reason: 'Iris bridge URL must use ws:// or wss://' }; + } + if ((token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) { + return { + allowed: false, + reason: `Iris pairing token exceeds ${String(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH)} characters`, + }; + } + const remoteBridge = !isLoopbackHostname(bridge.hostname); + if (remoteBridge && bridge.protocol !== 'wss:') { + return { allowed: false, reason: 'a non-local Iris bridge must use wss://' }; + } + const remote = !isLoopbackHostname(pageHostname) || remoteBridge; + if (!remote) return { allowed: true }; + if (!allowNonLocalhost) { + return { + allowed: false, + reason: 'Iris is disabled outside localhost unless allowNonLocalhost is explicitly enabled', + }; + } + if (token === undefined || token.length === 0) { + return { allowed: false, reason: 'a pairing token is required outside localhost' }; + } + return { allowed: true }; +} + function str(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback; } @@ -120,6 +165,7 @@ export class Iris { #presenter: Presenter | undefined; #recorder: RecorderHandle | undefined; #eventCount = 0; + #token: string | undefined; /** Act-row log handle for the in-flight act/act_sequence, so its outcome stamps the right row. */ #actHandle: LogHandle | undefined; @@ -127,11 +173,28 @@ export class Iris { if (this.#connected) return; if (typeof window === 'undefined' || typeof document === 'undefined') return; - this.#session = resolveSessionLabel(options.session, () => `s${Date.now().toString(36)}`); + const url = options.url ?? `ws://localhost:${String(IRIS_DEFAULT_PORT)}${IRIS_WS_PATH}`; + const policy = connectionPolicy( + window.location.hostname, + url, + options.allowNonLocalhost === true, + options.token, + ); + if (!policy.allowed) { + globalThis.console.warn(`[Iris] ${policy.reason ?? 'connection blocked'}`); + return; + } + + this.#session = resolveSessionLabel(options.session, () => + typeof globalThis.crypto?.randomUUID === 'function' + ? `s${globalThis.crypto.randomUUID()}` + : `s${Date.now().toString(36)}`, + ); + this.#token = + options.token !== undefined && options.token.length > 0 ? options.token : undefined; this.#start = performance.now(); this.#registry = createCommandRegistry(); - const url = options.url ?? `ws://localhost:${String(IRIS_DEFAULT_PORT)}${IRIS_WS_PATH}`; this.#transport = new Transport({ url, hello: () => this.#hello(), @@ -261,6 +324,7 @@ export class Iris { url: location.href, title: document.title, adapters: adapterNames(), + ...(this.#token === undefined ? {} : { token: this.#token }), hasCapabilities: hasCapabilities(), }; } diff --git a/packages/browser/src/observers/console.ts b/packages/browser/src/observers/console.ts index 7ce4b26..2fa8203 100644 --- a/packages/browser/src/observers/console.ts +++ b/packages/browser/src/observers/console.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console -- this module's whole purpose is to wrap console.{log,warn,error} */ import { EventType } from '@syrin/iris-protocol'; import type { Emit, Teardown } from './types.js'; +import { safeStringify } from '../security/serialization.js'; type ConsoleMethod = 'log' | 'warn' | 'error'; @@ -15,11 +16,7 @@ function stringifyArgs(args: unknown[]): string { .map((a) => { if (typeof a === 'string') return a; if (a instanceof Error) return a.message; - try { - return JSON.stringify(a); - } catch { - return String(a); - } + return safeStringify(a); }) .join(' '); } diff --git a/packages/browser/src/presenter/presenter.test.ts b/packages/browser/src/presenter/presenter.test.ts index 8ad0939..b214f18 100644 --- a/packages/browser/src/presenter/presenter.test.ts +++ b/packages/browser/src/presenter/presenter.test.ts @@ -768,7 +768,7 @@ describe('presenter glow state machine', () => { // Go quiet: jump clock past the idle window, let native timers fire the fade-out. t += 1000; - await wait(FAST_IDLE_MS + FAST_FADE_MS + 20); + expect(await until(() => p.glowPhase() === 'idle')).toBe(true); await flush(); expect(flips.enters).toBe(1); @@ -796,7 +796,7 @@ describe('presenter glow state machine', () => { expect(p.glowPhase()).toBe('busy'); t += 1000; - await wait(FAST_IDLE_MS + FAST_FADE_MS + 20); + expect(await until(() => p.glowPhase() === 'idle')).toBe(true); await flush(); expect(p.glowPhase()).toBe('idle'); @@ -822,7 +822,7 @@ describe('presenter glow state machine', () => { p.status('one'); t += 1000; - await wait(FAST_IDLE_MS + 10); // idle check fires -> begins fade + expect(await until(() => p.glowPhase() === 'fading')).toBe(true); expect(p.glowPhase()).toBe('fading'); p.status('resumed'); // activity during fade diff --git a/packages/browser/src/registry/stores.test.ts b/packages/browser/src/registry/stores.test.ts index 302d8b0..90164d1 100644 --- a/packages/browser/src/registry/stores.test.ts +++ b/packages/browser/src/registry/stores.test.ts @@ -30,6 +30,20 @@ describe('store registry', () => { unregisterStore('ws_ok'); }); + it('returns redacted, JSON-safe state for secrets, BigInt, and cycles', () => { + const state: Record = { password: 'secret', count: 2n }; + state['self'] = state; + registerStore('ws_safe', () => state); + const out = readStores('ws_safe'); + expect(out['ws_safe']).toEqual({ + password: '[REDACTED]', + count: '2', + self: '[CIRCULAR]', + }); + expect(() => JSON.stringify(out)).not.toThrow(); + unregisterStore('ws_safe'); + }); + it('unregisterStore removes it', () => { registerStore('ws_d', () => 0); expect(storeNames()).toContain('ws_d'); diff --git a/packages/browser/src/registry/stores.ts b/packages/browser/src/registry/stores.ts index 3513d37..86fcbb7 100644 --- a/packages/browser/src/registry/stores.ts +++ b/packages/browser/src/registry/stores.ts @@ -1,5 +1,6 @@ -/** Store registry — lets the agent pull live framework/store state on demand. */ +import { sanitizeForTransport } from '../security/serialization.js'; +/** Store registry — lets the agent pull live framework/store state on demand. */ export type StoreGetter = () => unknown; // Persist on a global so registrations survive HMR re-evaluation (see adapters.ts / feedback #7). @@ -25,7 +26,7 @@ export function readStores(only?: string): Record { for (const [name, getter] of stores) { if (only !== undefined && name !== only) continue; try { - out[name] = getter(); + out[name] = sanitizeForTransport(getter()); } catch (error) { out[name] = { __error: error instanceof Error ? error.message : String(error) }; } diff --git a/packages/browser/src/security/serialization.test.ts b/packages/browser/src/security/serialization.test.ts new file mode 100644 index 0000000..15b8e16 --- /dev/null +++ b/packages/browser/src/security/serialization.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { REDACTED_VALUE, TRANSPORT_LIMITS } from '@syrin/iris-protocol'; +import { safeStringify, sanitizeForTransport } from './serialization.js'; + +describe('transport serialization', () => { + it('redacts sensitive keys at every depth', () => { + expect( + sanitizeForTransport({ + password: 'open-sesame', + nested: { apiKey: 'key-123', value: 1 }, + }), + ).toEqual({ + password: REDACTED_VALUE, + nested: { apiKey: REDACTED_VALUE, value: 1 }, + }); + }); + + it('handles BigInt and cycles without throwing', () => { + const value: Record = { count: 2n }; + value['self'] = value; + expect(() => safeStringify(value)).not.toThrow(); + expect(JSON.parse(safeStringify(value))).toEqual({ + count: '2', + self: '[CIRCULAR]', + }); + }); + + it('omits undefined object properties and preserves array positions', () => { + expect( + JSON.parse( + safeStringify({ + omitted: undefined, + items: [undefined, () => undefined, Symbol('value')], + }), + ), + ).toEqual({ items: [null, null, null] }); + }); + + it('contains hostile proxy failures', () => { + const proxy = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('blocked'); + }, + }, + ); + expect(safeStringify(proxy)).toBe('"[UNSERIALIZABLE]"'); + }); + + it('bounds long strings and collections', () => { + const result = sanitizeForTransport({ + text: 'x'.repeat(TRANSPORT_LIMITS.MAX_STRING_LENGTH + 100), + items: Array.from({ length: TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS + 10 }, (_, i) => i), + }) as { text: string; items: unknown[] }; + expect(result.text.length).toBeLessThanOrEqual(TRANSPORT_LIMITS.MAX_STRING_LENGTH); + expect(result.text.endsWith('[TRUNCATED]')).toBe(true); + expect(result.items).toHaveLength(TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS); + }); +}); diff --git a/packages/browser/src/security/serialization.ts b/packages/browser/src/security/serialization.ts new file mode 100644 index 0000000..1dd1101 --- /dev/null +++ b/packages/browser/src/security/serialization.ts @@ -0,0 +1,119 @@ +import { REDACTED_VALUE, TRANSPORT_LIMITS } from '@syrin/iris-protocol'; + +const TRUNCATED_VALUE = '[TRUNCATED]'; +const UNSERIALIZABLE_VALUE = '[UNSERIALIZABLE]'; +const OMIT_VALUE = Symbol('omit'); +const MAX_KEY_LENGTH = 256; +const MAX_TOTAL_CHARACTERS = Math.floor(TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / 8); +const MAX_TOTAL_NODES = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS * 5; + +const SENSITIVE_KEY = + /password|passwd|passcode|secret|token|authorization|api[-_]?key|access[-_]?key|private[-_]?key|client[-_]?secret|credit[-_]?card|card[-_]?number|cvv|cvc|ssn/i; + +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY.test(key); +} + +interface SanitizeState { + readonly seen: WeakSet; + remainingCharacters: number; + nodes: number; +} + +function boundedString(value: string, state: SanitizeState, max: number): string { + const allowed = Math.max(0, Math.min(max, state.remainingCharacters)); + if (value.length <= allowed) { + state.remainingCharacters -= value.length; + return value; + } + const truncated = + allowed <= TRUNCATED_VALUE.length + ? TRUNCATED_VALUE.slice(0, allowed) + : `${value.slice(0, allowed - TRUNCATED_VALUE.length)}${TRUNCATED_VALUE}`; + state.remainingCharacters -= truncated.length; + return truncated; +} + +function sanitize(value: unknown, state: SanitizeState, depth: number, key?: string): unknown { + if (key !== undefined && isSensitiveKey(key)) return REDACTED_VALUE; + if (depth > TRANSPORT_LIMITS.MAX_SERIALIZE_DEPTH || state.nodes >= MAX_TOTAL_NODES) { + return TRUNCATED_VALUE; + } + state.nodes += 1; + + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'string') { + return boundedString( + value, + state, + key?.toLowerCase() === 'error' + ? TRANSPORT_LIMITS.MAX_ERROR_LENGTH + : TRANSPORT_LIMITS.MAX_STRING_LENGTH, + ); + } + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') { + return OMIT_VALUE; + } + if (value instanceof Date) return value.toISOString(); + if (value instanceof Error) { + return { + name: boundedString(value.name, state, 256), + message: boundedString(value.message, state, TRANSPORT_LIMITS.MAX_ERROR_LENGTH), + }; + } + if (state.seen.has(value)) return '[CIRCULAR]'; + + state.seen.add(value); + try { + if (Array.isArray(value)) { + return value.slice(0, TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS).map((item) => { + const sanitized = sanitize(item, state, depth + 1); + return sanitized === OMIT_VALUE ? null : sanitized; + }); + } + + const out = Object.create(null) as Record; + for (const rawKey of Object.keys(value).slice(0, TRANSPORT_LIMITS.MAX_OBJECT_KEYS)) { + const safeKey = boundedString(rawKey, state, MAX_KEY_LENGTH); + try { + const sanitized = sanitize( + (value as Record)[rawKey], + state, + depth + 1, + rawKey, + ); + if (sanitized !== OMIT_VALUE) out[safeKey] = sanitized; + } catch { + out[safeKey] = UNSERIALIZABLE_VALUE; + } + } + return out; + } finally { + state.seen.delete(value); + } +} + +/** Convert arbitrary app state into a bounded, redacted JSON-compatible value. */ +export function sanitizeForTransport(value: unknown): unknown { + const sanitized = sanitize( + value, + { + seen: new WeakSet(), + remainingCharacters: MAX_TOTAL_CHARACTERS, + nodes: 0, + }, + 0, + ); + return sanitized === OMIT_VALUE ? null : sanitized; +} + +/** Serialize without allowing cycles, BigInt, getters, or secrets to break the transport. */ +export function safeStringify(value: unknown): string { + try { + return JSON.stringify(sanitizeForTransport(value)); + } catch { + return JSON.stringify(UNSERIALIZABLE_VALUE); + } +} diff --git a/packages/browser/src/timers/native-timers.interval.test.ts b/packages/browser/src/timers/native-timers.interval.test.ts index 9e21426..8a16e32 100644 --- a/packages/browser/src/timers/native-timers.interval.test.ts +++ b/packages/browser/src/timers/native-timers.interval.test.ts @@ -13,10 +13,12 @@ describe('nativeSetInterval', () => { it('fires repeatedly until stopped', async () => { const cb = vi.fn(); const stop = nativeSetInterval(cb, 5); - await new Promise((r) => setTimeout(r, 30)); + await vi.waitFor(() => expect(cb.mock.calls.length).toBeGreaterThanOrEqual(2), { + timeout: 1_000, + interval: 10, + }); stop(); const count = cb.mock.calls.length; - expect(count).toBeGreaterThanOrEqual(2); await new Promise((r) => setTimeout(r, 20)); expect(cb.mock.calls.length).toBe(count); // no more after stop }); diff --git a/packages/browser/src/transport/transport.security.test.ts b/packages/browser/src/transport/transport.security.test.ts new file mode 100644 index 0000000..ee184ff --- /dev/null +++ b/packages/browser/src/transport/transport.security.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + CommandResultSchema, + EventType, + IRIS_PROTOCOL_VERSION, + MessageKind, + TRANSPORT_LIMITS, + type HelloMessage, +} from '@syrin/iris-protocol'; +import { Transport } from './transport.js'; + +class FakeWebSocket { + static readonly OPEN = 1; + static instances: FakeWebSocket[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + readyState = 0; + sent: string[] = []; + + constructor(public readonly url: string) { + FakeWebSocket.instances.push(this); + } + + send(text: string): void { + this.sent.push(text); + } + + close(): void { + this.readyState = 3; + } + + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + receive(value: unknown): void { + this.onmessage?.({ data: JSON.stringify(value) } as MessageEvent); + } +} + +const hello = (): HelloMessage => ({ + kind: MessageKind.HELLO, + protocolVersion: IRIS_PROTOCOL_VERSION, + sessionId: 'session-a', + url: 'http://localhost/', + title: 'Test', + adapters: [], +}); + +beforeEach(() => { + FakeWebSocket.instances = []; + (globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeWebSocket; +}); + +afterEach(() => { + delete (globalThis as unknown as { WebSocket?: unknown }).WebSocket; +}); + +describe('Transport security', () => { + it('preserves the SDK-owned pairing token in HELLO', () => { + const transport = new Transport({ + url: 'ws://localhost/iris', + hello: () => ({ ...hello(), token: 'shared-secret' }), + handleCommand: () => Promise.resolve({ ok: true }), + }); + transport.connect(); + const socket = FakeWebSocket.instances[0]; + socket?.open(); + expect(JSON.parse(socket?.sent[0] ?? '{}')).toMatchObject({ token: 'shared-secret' }); + }); + + it('ignores malformed and cross-session commands', async () => { + let handled = 0; + const transport = new Transport({ + url: 'ws://localhost/iris', + hello, + handleCommand: () => { + handled += 1; + return Promise.resolve({ ok: true }); + }, + }); + transport.connect(); + const socket = FakeWebSocket.instances[0]; + socket?.open(); + socket?.receive({ kind: MessageKind.COMMAND, id: '', name: 'snapshot', args: {} }); + socket?.receive({ + kind: MessageKind.COMMAND, + id: 'c1', + sessionId: 'session-b', + name: 'snapshot', + args: {}, + }); + await Promise.resolve(); + expect(handled).toBe(0); + }); + + it('redacts and serializes arbitrary command results', async () => { + const value: Record = { password: 'secret', count: 2n }; + value['self'] = value; + const transport = new Transport({ + url: 'ws://localhost/iris', + hello, + handleCommand: () => Promise.resolve({ ok: true, result: value }), + }); + transport.connect(); + const socket = FakeWebSocket.instances[0]; + socket?.open(); + socket?.receive({ + kind: MessageKind.COMMAND, + id: 'c1', + sessionId: 'session-a', + name: 'state_read', + args: {}, + }); + await Promise.resolve(); + await Promise.resolve(); + const response = JSON.parse(socket?.sent.at(-1) ?? '{}') as { + result?: Record; + }; + expect(response.result).toEqual({ + password: '[REDACTED]', + count: '2', + self: '[CIRCULAR]', + }); + }); + + it('omits absent optional event fields instead of sending null', () => { + const transport = new Transport({ + url: 'ws://localhost/iris', + hello, + handleCommand: () => Promise.resolve({ ok: true }), + }); + transport.connect(); + const socket = FakeWebSocket.instances[0]; + socket?.open(); + transport.sendEvent({ + t: 1, + type: EventType.PAGE_HEALTH, + sessionId: 'session-a', + ref: undefined, + data: { hidden: false, focused: true, reason: 'initial' }, + }); + const event = JSON.parse(socket?.sent.at(-1) ?? '{}') as { + event?: Record; + }; + expect(event.event).not.toHaveProperty('ref'); + }); + + it('keeps thrown command errors within the wire schema limit', async () => { + const transport = new Transport({ + url: 'ws://localhost/iris', + hello, + handleCommand: () => { + throw new Error('x'.repeat(TRANSPORT_LIMITS.MAX_ERROR_LENGTH + 100)); + }, + }); + transport.connect(); + const socket = FakeWebSocket.instances[0]; + socket?.open(); + socket?.receive({ + kind: MessageKind.COMMAND, + id: 'c1', + sessionId: 'session-a', + name: 'state_read', + args: {}, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(() => CommandResultSchema.parse(JSON.parse(socket?.sent.at(-1) ?? '{}'))).not.toThrow(); + }); +}); diff --git a/packages/browser/src/transport/transport.ts b/packages/browser/src/transport/transport.ts index 00e8891..1bee7e3 100644 --- a/packages/browser/src/transport/transport.ts +++ b/packages/browser/src/transport/transport.ts @@ -1,4 +1,5 @@ import { + CommandMessageSchema, MessageKind, SESSION_LIFECYCLE, type CommandMessage, @@ -6,6 +7,7 @@ import { type IrisEvent, } from '@syrin/iris-protocol'; import { nativeSetTimeout, nativeNow } from '../timers/native-timers.js'; +import { safeStringify } from '../security/serialization.js'; export interface CommandOutcome { ok: boolean; @@ -65,6 +67,8 @@ export class Transport { ws.onopen = (): void => { this.#disconnectedSince = undefined; // healthy again — reset the loss timer this.#lost = false; + // HELLO is SDK-owned schema data. Preserve its pairing token; the generic sanitizer + // intentionally redacts fields named "token" from app-controlled payloads. ws.send(JSON.stringify(this.#deps.hello())); for (const msg of this.#queue) ws.send(msg); this.#queue = []; @@ -110,12 +114,22 @@ export class Transport { } catch { return; } - const msg = parsed as { kind?: string }; - if (msg.kind !== MessageKind.COMMAND) return; - const command = parsed as CommandMessage; - const outcome = await this.#deps.handleCommand(command); + const result = CommandMessageSchema.safeParse(parsed); + if (!result.success) return; + const command = result.data; + const currentSessionId = this.#deps.hello().sessionId; + if (command.sessionId !== undefined && command.sessionId !== currentSessionId) return; + let outcome: CommandOutcome; + try { + outcome = await this.#deps.handleCommand(command); + } catch (error) { + outcome = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } this.#sendRaw( - JSON.stringify({ + safeStringify({ kind: MessageKind.COMMAND_RESULT, id: command.id, ok: outcome.ok, @@ -126,7 +140,7 @@ export class Transport { } sendEvent(event: IrisEvent): void { - this.#sendRaw(JSON.stringify({ kind: MessageKind.EVENT, event })); + this.#sendRaw(safeStringify({ kind: MessageKind.EVENT, event })); } #sendRaw(text: string): void { diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 7cbc0a7..c471f59 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -15,7 +15,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "dependencies": { diff --git a/packages/iris/package.json b/packages/iris/package.json index 1b2933e..d5ff4cd 100644 --- a/packages/iris/package.json +++ b/packages/iris/package.json @@ -74,7 +74,7 @@ "eslint": ">=9", "next": ">=13", "react": ">=18", - "vitest": "^3.0.0" + "vitest": "^3.2.6" }, "peerDependenciesMeta": { "eslint": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 9939fce..965ac52 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -15,7 +15,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "dependencies": { diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts index 14a716e..4818ba5 100644 --- a/packages/protocol/src/constants.ts +++ b/packages/protocol/src/constants.ts @@ -7,6 +7,36 @@ export const IRIS_DEFAULT_PORT = 4400; export const IRIS_WS_PATH = '/iris'; export const IRIS_PROTOCOL_VERSION = 1; +/** Hard transport bounds shared by the browser and bridge. */ +export const TRANSPORT_LIMITS = { + MAX_MESSAGE_BYTES: 1024 * 1024, + MAX_MESSAGES_PER_SECOND: 1000, + MAX_SESSIONS: 32, + MAX_PENDING_CONNECTIONS: 16, + HELLO_TIMEOUT_MS: 5000, + MAX_BUFFER_BYTES: 8 * 1024 * 1024, + MAX_SESSION_ID_LENGTH: 128, + MAX_URL_LENGTH: 4096, + MAX_TITLE_LENGTH: 512, + MAX_ADAPTERS: 32, + MAX_ADAPTER_NAME_LENGTH: 128, + MAX_TOKEN_LENGTH: 512, + MAX_COMMAND_ID_LENGTH: 128, + MAX_COMMAND_NAME_LENGTH: 128, + MAX_REF_LENGTH: 128, + MAX_ERROR_LENGTH: 4096, + MAX_SERIALIZE_DEPTH: 8, + MAX_COLLECTION_ITEMS: 200, + MAX_OBJECT_KEYS: 200, + MAX_STRING_LENGTH: 64 * 1024, +} as const; + +/** Replacement used when sensitive data is removed before crossing the bridge. */ +export const REDACTED_VALUE = '[REDACTED]'; + +/** Explicit opt-in argument required for potentially destructive actions. */ +export const DANGEROUS_ACTION_CONFIRM_ARG = 'confirmDangerous'; + /** Schema version stamped onto compiled replay programs. */ export const REPLAY_PROGRAM_VERSION = 1; @@ -164,12 +194,12 @@ export const FLOW_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/i; /** * The outcome of replaying an on-disk flow by re-resolving its * semantic anchors against the live DOM. `drift` (an anchor missed → contract changed) is - * cleanly separated from `error` (the flow file is missing/malformed) and `ok`. No free strings. + * cleanly separated from `error` (the flow could not load or an action failed) and `ok`. */ export const ReplayStatus = { OK: 'ok', // every anchor resolved and every step ran green DRIFT: 'drift', // an anchor missed (testid renamed / signal not observed) — legible drift returned - ERROR: 'error', // the flow file could not be loaded (missing/invalid) — no steps ran + ERROR: 'error', // the flow could not load or a resolved action failed } as const; export type ReplayStatus = (typeof ReplayStatus)[keyof typeof ReplayStatus]; @@ -228,7 +258,7 @@ export const HealStatus = { DRIFT: 'drift', // apply:false: confident proposal(s) returned, file untouched UNHEALABLE: 'unhealable', // drift exists but no proposal cleared the confidence floor NOTHING_TO_HEAL: 'nothing_to_heal', // replay was green - ERROR: 'error', // flow missing/malformed/invalid-name — no steps ran + ERROR: 'error', // flow missing/malformed/invalid-name, or a resolved action failed } as const; export type HealStatus = (typeof HealStatus)[keyof typeof HealStatus]; @@ -272,6 +302,7 @@ export const COMPILED_PREDICATE_PREFIX = 'will'; export const RING_BUFFER_DEFAULTS = { MAX_EVENTS: 2000, MAX_AGE_MS: 60_000, + MAX_BYTES: TRANSPORT_LIMITS.MAX_BUFFER_BYTES, } as const; /** The observers that can be installed in the browser SDK (plan/03). */ diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 917ab02..a23197d 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,4 +1,5 @@ export * from './constants.js'; export * from './messages.js'; +export * from './security.js'; export * from './toon.js'; export * from './types.js'; diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts new file mode 100644 index 0000000..fd457a7 --- /dev/null +++ b/packages/protocol/src/messages.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { IRIS_PROTOCOL_VERSION, MessageKind, TRANSPORT_LIMITS } from './constants.js'; +import { HelloMessageSchema } from './messages.js'; + +function hello(): Record { + return { + kind: MessageKind.HELLO, + protocolVersion: IRIS_PROTOCOL_VERSION, + sessionId: 'demo', + url: 'http://localhost:3000/', + title: 'Demo', + adapters: [], + }; +} + +describe('HelloMessageSchema', () => { + it('accepts a bounded optional pairing token', () => { + expect(HelloMessageSchema.parse({ ...hello(), token: 'shared-secret' }).token).toBe( + 'shared-secret', + ); + }); + + it('rejects a mismatched protocol version', () => { + expect( + HelloMessageSchema.safeParse({ ...hello(), protocolVersion: IRIS_PROTOCOL_VERSION + 1 }) + .success, + ).toBe(false); + }); + + it('rejects oversized identity fields', () => { + expect( + HelloMessageSchema.safeParse({ + ...hello(), + sessionId: 's'.repeat(TRANSPORT_LIMITS.MAX_SESSION_ID_LENGTH + 1), + }).success, + ).toBe(false); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index aefce42..f885801 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,5 +1,14 @@ import { z } from 'zod'; -import { EventType, HumanControlKind, MessageKind } from './constants.js'; +import { + EventType, + HumanControlKind, + IRIS_PROTOCOL_VERSION, + MessageKind, + TRANSPORT_LIMITS, +} from './constants.js'; + +const sessionIdSchema = z.string().min(1).max(TRANSPORT_LIMITS.MAX_SESSION_ID_LENGTH); +const refSchema = z.string().max(TRANSPORT_LIMITS.MAX_REF_LENGTH); /** * Live-control: the narrowed payload of a HUMAN_CONTROL event. The server safeParses @@ -19,9 +28,9 @@ export type HumanControlData = z.infer; export const IrisEventSchema = z.object({ t: z.number(), type: z.nativeEnum(EventType), - sessionId: z.string(), + sessionId: sessionIdSchema, /** Stable element reference this event concerns, when applicable (e.g. "e7"). */ - ref: z.string().optional(), + ref: refSchema.optional(), /** Event-type-specific payload. Kept open here; refined per observer at the edges. */ data: z.record(z.unknown()).default({}), }); @@ -30,11 +39,15 @@ export type IrisEvent = z.infer; /** Browser announces itself to the bridge on connect. */ export const HelloMessageSchema = z.object({ kind: z.literal(MessageKind.HELLO), - protocolVersion: z.number(), - sessionId: z.string(), - url: z.string(), - title: z.string(), - adapters: z.array(z.string()), + protocolVersion: z.literal(IRIS_PROTOCOL_VERSION), + sessionId: sessionIdSchema, + url: z.string().max(TRANSPORT_LIMITS.MAX_URL_LENGTH), + title: z.string().max(TRANSPORT_LIMITS.MAX_TITLE_LENGTH), + adapters: z + .array(z.string().max(TRANSPORT_LIMITS.MAX_ADAPTER_NAME_LENGTH)) + .max(TRANSPORT_LIMITS.MAX_ADAPTERS), + /** Optional browser/bridge pairing token. Required when the bridge configures one. */ + token: z.string().max(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH).optional(), /** Whether the app has advertised a capability registry (iris.describe). */ hasCapabilities: z.boolean().optional(), }); @@ -43,9 +56,9 @@ export type HelloMessage = z.infer; /** Agent -> browser request, routed by the bridge with a correlation id. */ export const CommandMessageSchema = z.object({ kind: z.literal(MessageKind.COMMAND), - id: z.string(), - sessionId: z.string().optional(), - name: z.string(), + id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH), + sessionId: sessionIdSchema.optional(), + name: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_NAME_LENGTH), args: z.record(z.unknown()).default({}), }); export type CommandMessage = z.infer; @@ -53,10 +66,10 @@ export type CommandMessage = z.infer; /** Browser -> agent reply to a command. */ export const CommandResultSchema = z.object({ kind: z.literal(MessageKind.COMMAND_RESULT), - id: z.string(), + id: z.string().min(1).max(TRANSPORT_LIMITS.MAX_COMMAND_ID_LENGTH), ok: z.boolean(), result: z.unknown().optional(), - error: z.string().optional(), + error: z.string().max(TRANSPORT_LIMITS.MAX_ERROR_LENGTH).optional(), }); export type CommandResult = z.infer; diff --git a/packages/protocol/src/security.test.ts b/packages/protocol/src/security.test.ts new file mode 100644 index 0000000..57ac04f --- /dev/null +++ b/packages/protocol/src/security.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { isDangerousActionText, isLoopbackHostname } from './security.js'; + +describe('isLoopbackHostname', () => { + it('accepts literal IPv4, IPv6, and localhost loopback hosts', () => { + expect(isLoopbackHostname('localhost')).toBe(true); + expect(isLoopbackHostname('127.0.0.1')).toBe(true); + expect(isLoopbackHostname('127.255.255.254')).toBe(true); + expect(isLoopbackHostname('[::1]')).toBe(true); + }); + + it('rejects DNS lookalikes and invalid IPv4 literals', () => { + expect(isLoopbackHostname('127.evil.example')).toBe(false); + expect(isLoopbackHostname('localhost.example')).toBe(false); + expect(isLoopbackHostname('127.0.0.999')).toBe(false); + }); +}); + +describe('isDangerousActionText', () => { + it('matches destructive labels and separator-delimited tool names', () => { + expect(isDangerousActionText('Delete account')).toBe(true); + expect(isDangerousActionText('delete_account')).toBe(true); + expect(isDangerousActionText('transfer-funds')).toBe(true); + }); + + it('does not block ordinary read-only controls', () => { + expect(isDangerousActionText('Search records')).toBe(false); + expect(isDangerousActionText('Open settings')).toBe(false); + }); +}); diff --git a/packages/protocol/src/security.ts b/packages/protocol/src/security.ts new file mode 100644 index 0000000..e1881df --- /dev/null +++ b/packages/protocol/src/security.ts @@ -0,0 +1,25 @@ +const DANGEROUS_ACTION = + /\b(delete|remove|destroy|erase|drop|terminate|revoke|reset|logout|log out|sign out|close account|cancel subscription|purchase|buy|pay|place order|confirm order|deploy|publish|send|transfer|withdraw|refund)\b/i; + +/** True only for literal loopback hosts, never lookalike DNS names such as 127.example.com. */ +export function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') { + return true; + } + const octets = normalized.split('.'); + return ( + octets.length === 4 && + octets[0] === '127' && + octets.every((octet) => { + if (!/^\d{1,3}$/.test(octet)) return false; + const value = Number(octet); + return value >= 0 && value <= 255; + }) + ); +} + +/** Best-effort classifier for labels and tool names that can trigger irreversible effects. */ +export function isDangerousActionText(text: string): boolean { + return DANGEROUS_ACTION.test(text.replace(/[_-]+/g, ' ')); +} diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 0b70656..cfc3598 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -251,7 +251,7 @@ export interface FlowReplayResult { name: string; status: ReplayStatus; steps: FlowStepResult[]; - /** Set when status === 'error' (missing/invalid file) — no steps ran. */ + /** Set when status === 'error' (load failure or resolved action failure). */ error?: { code: string; message: string }; /** * The confident rebind proposals aggregated across drifted steps (additive, diff --git a/packages/react/package.json b/packages/react/package.json index d316484..6c5c591 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -15,7 +15,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "dependencies": { diff --git a/packages/server/.env.example b/packages/server/.env.example index 964ed72..4b80949 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -3,7 +3,14 @@ # Port the bridge WebSocket endpoint + MCP server listen on. IRIS_PORT=4400 +# Keep the default loopback bind unless remote browser access is intentional. +IRIS_HOST=127.0.0.1 + # Shared pairing token between the browser SDK and the bridge. # Generate one and set the same value in the app's @syrin/iris-browser config. # Leave blank in pure-localhost dev; required for any non-localhost transport. +# Terminate TLS in front of the bridge and use wss:// from browsers when it is remote. IRIS_TOKEN= + +# Comma-separated browser origins allowed in addition to localhost. +IRIS_ALLOWED_ORIGINS= diff --git a/packages/server/package.json b/packages/server/package.json index 1e8411b..54e2249 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,7 +22,7 @@ "dev": "tsc -b --watch", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "dependencies": { diff --git a/packages/server/src/bridge.security.test.ts b/packages/server/src/bridge.security.test.ts new file mode 100644 index 0000000..3515a4b --- /dev/null +++ b/packages/server/src/bridge.security.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; +import { + EventType, + IRIS_PROTOCOL_VERSION, + IRIS_WS_PATH, + MessageKind, + TRANSPORT_LIMITS, +} from '@syrin/iris-protocol'; +import { Bridge } from './bridge.js'; + +const bridges: Bridge[] = []; +const sockets: WebSocket[] = []; + +function hello(sessionId: string, token?: string): Record { + return { + kind: MessageKind.HELLO, + protocolVersion: IRIS_PROTOCOL_VERSION, + sessionId, + url: 'http://localhost/', + title: 'Security test', + adapters: [], + ...(token === undefined ? {} : { token }), + }; +} + +async function makeBridge(options: Omit[0], 'port'> = {}) { + const bridge = new Bridge({ port: 0, ...options }); + bridges.push(bridge); + return { bridge, port: await bridge.ready }; +} + +function openSocket(port: number, origin?: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${String(port)}${IRIS_WS_PATH}`, { + ...(origin === undefined ? {} : { origin }), + }); + sockets.push(socket); + socket.once('open', () => resolve(socket)); + socket.once('error', reject); + }); +} + +function waitForClose(socket: WebSocket): Promise { + return new Promise((resolve) => { + socket.once('close', (code) => resolve(code)); + }); +} + +function waitUntil(condition: () => boolean, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const poll = (): void => { + if (condition()) { + resolve(); + } else if (Date.now() - startedAt >= timeoutMs) { + reject(new Error('condition timed out')); + } else { + setTimeout(poll, 10); + } + }; + poll(); + }); +} + +afterEach(async () => { + for (const socket of sockets.splice(0)) socket.terminate(); + await Promise.all(bridges.splice(0).map((bridge) => bridge.close())); +}); + +describe('Bridge security boundary', () => { + it('rejects non-local browser origins by default', async () => { + const { port } = await makeBridge(); + await expect(openSocket(port, 'https://evil.example')).rejects.toThrow( + /Unexpected server response: 403/, + ); + await expect(openSocket(port, 'http://127.evil.example')).rejects.toThrow( + /Unexpected server response: 403/, + ); + }); + + it('accepts configured origins and requires the pairing token', async () => { + const { bridge, port } = await makeBridge({ + token: 'shared-secret', + allowedOrigins: ['https://app.example'], + }); + const bad = await openSocket(port, 'https://app.example'); + const badClosed = waitForClose(bad); + bad.send(JSON.stringify(hello('bad', 'wrong-secret'))); + expect(await badClosed).toBe(1008); + expect(bridge.sessions.count()).toBe(0); + + const good = await openSocket(port, 'https://app.example'); + good.send(JSON.stringify(hello('good', 'shared-secret'))); + await waitUntil(() => bridge.sessions.count() === 1); + expect(bridge.sessions.get('good')).toBeDefined(); + }); + + it('requires a token before binding beyond localhost', () => { + expect(() => new Bridge({ port: 0, host: '0.0.0.0' })).toThrow(/pairing token/); + }); + + it('rejects protocol mismatches', async () => { + const { bridge, port } = await makeBridge(); + const socket = await openSocket(port); + const closed = waitForClose(socket); + socket.send( + JSON.stringify({ + ...hello('old-client'), + protocolVersion: IRIS_PROTOCOL_VERSION + 1, + }), + ); + expect(await closed).toBe(1008); + expect(bridge.sessions.count()).toBe(0); + }); + + it('keeps a replacement session when the older duplicate socket closes', async () => { + const { bridge, port } = await makeBridge(); + const first = await openSocket(port); + first.send(JSON.stringify(hello('same-id'))); + await waitUntil(() => bridge.sessions.count() === 1); + + const second = await openSocket(port); + const firstClosed = waitForClose(first); + second.send(JSON.stringify(hello('same-id'))); + expect(await firstClosed).toBe(1008); + await waitUntil(() => bridge.sessions.count() === 1); + + second.send( + JSON.stringify({ + kind: MessageKind.EVENT, + event: { + t: 1, + type: EventType.SIGNAL, + sessionId: 'same-id', + data: { name: 'still-connected' }, + }, + }), + ); + await waitUntil(() => bridge.sessions.resolve('same-id').eventsSince(0).length === 1); + }); + + it('caps concurrent sessions and message rate', async () => { + const limitedSessions = await makeBridge({ maxSessions: 1 }); + const first = await openSocket(limitedSessions.port); + first.send(JSON.stringify(hello('one'))); + await waitUntil(() => limitedSessions.bridge.sessions.count() === 1); + const second = await openSocket(limitedSessions.port); + const sessionLimitClose = waitForClose(second); + second.send(JSON.stringify(hello('two'))); + expect(await sessionLimitClose).toBe(1013); + + const rateLimited = await makeBridge({ maxMessagesPerSecond: 2 }); + const noisy = await openSocket(rateLimited.port); + noisy.send(JSON.stringify(hello('noisy'))); + noisy.send(JSON.stringify({ kind: MessageKind.COMMAND_RESULT, id: 'c1', ok: true })); + const rateClose = waitForClose(noisy); + noisy.send(JSON.stringify({ kind: MessageKind.COMMAND_RESULT, id: 'c2', ok: true })); + expect(await rateClose).toBe(1008); + }); + + it('caps and expires unauthenticated pending handshakes', async () => { + const limited = await makeBridge({ + maxPendingConnections: 1, + helloTimeoutMs: 50, + }); + const idle = await openSocket(limited.port); + const idleClosed = waitForClose(idle); + + const excess = await openSocket(limited.port); + const excessClosed = waitForClose(excess); + expect(await excessClosed).toBe(1013); + expect(await idleClosed).toBe(1008); + expect(limited.bridge.sessions.count()).toBe(0); + }); + + it('rejects messages above the transport payload limit', async () => { + const { port } = await makeBridge(); + const socket = await openSocket(port); + const closed = waitForClose(socket); + socket.send(Buffer.alloc(TRANSPORT_LIMITS.MAX_MESSAGE_BYTES + 1)); + expect(await closed).toBe(1009); + }); +}); diff --git a/packages/server/src/bridge.ts b/packages/server/src/bridge.ts index d8db7f4..71f7619 100644 --- a/packages/server/src/bridge.ts +++ b/packages/server/src/bridge.ts @@ -1,15 +1,45 @@ +import { timingSafeEqual } from 'node:crypto'; import type { AddressInfo } from 'node:net'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; -import { IRIS_WS_PATH, IrisMessageSchema, MessageKind } from '@syrin/iris-protocol'; +import { + IRIS_WS_PATH, + IrisMessageSchema, + MessageKind, + TRANSPORT_LIMITS, + isLoopbackHostname, +} from '@syrin/iris-protocol'; import { Session, SessionManager } from './session/session.js'; import { log } from './log.js'; export interface BridgeOptions { port: number; host?: string; + token?: string; + allowedOrigins?: string[]; + maxMessagesPerSecond?: number; + maxSessions?: number; + maxPendingConnections?: number; + helloTimeoutMs?: number; clock?: () => number; } +function normalizeOrigin(origin: string): string | null { + try { + return new URL(origin).origin; + } catch { + return null; + } +} + +function tokensMatch(expected: string, received: string | undefined): boolean { + if (received === undefined) return false; + const expectedBytes = Buffer.from(expected); + const receivedBytes = Buffer.from(received); + return ( + expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes) + ); +} + /** Normalize ws RawData (string | Buffer | Buffer[] | ArrayBuffer) into a UTF-8 string. */ function rawToString(raw: RawData): string { if (typeof raw === 'string') return raw; @@ -29,13 +59,48 @@ export class Bridge { readonly ready: Promise; readonly #wss: WebSocketServer; readonly #clock: () => number; + readonly #token: string | undefined; + readonly #allowedOrigins: Set; + readonly #maxMessagesPerSecond: number; + readonly #maxSessions: number; + readonly #maxPendingConnections: number; + readonly #helloTimeoutMs: number; + #pendingConnections = 0; constructor(options: BridgeOptions) { + const host = options.host ?? '127.0.0.1'; + if ((options.token?.length ?? 0) > TRANSPORT_LIMITS.MAX_TOKEN_LENGTH) { + throw new Error( + `Iris pairing token exceeds ${String(TRANSPORT_LIMITS.MAX_TOKEN_LENGTH)} characters`, + ); + } + if (!isLoopbackHostname(host) && (options.token === undefined || options.token.length === 0)) { + throw new Error('a pairing token is required when the Iris bridge binds beyond localhost'); + } this.#clock = options.clock ?? (() => Date.now()); + this.#token = + options.token !== undefined && options.token.length > 0 ? options.token : undefined; + this.#allowedOrigins = new Set( + (options.allowedOrigins ?? []) + .map(normalizeOrigin) + .filter((origin): origin is string => origin !== null), + ); + this.#maxMessagesPerSecond = + options.maxMessagesPerSecond ?? TRANSPORT_LIMITS.MAX_MESSAGES_PER_SECOND; + this.#maxSessions = options.maxSessions ?? TRANSPORT_LIMITS.MAX_SESSIONS; + this.#maxPendingConnections = + options.maxPendingConnections ?? TRANSPORT_LIMITS.MAX_PENDING_CONNECTIONS; + this.#helloTimeoutMs = options.helloTimeoutMs ?? TRANSPORT_LIMITS.HELLO_TIMEOUT_MS; this.#wss = new WebSocketServer({ port: options.port, - host: options.host ?? '127.0.0.1', + host, path: IRIS_WS_PATH, + maxPayload: TRANSPORT_LIMITS.MAX_MESSAGE_BYTES, + verifyClient: ({ origin }, done) => { + const allowed = this.#originAllowed(origin); + if (!allowed) log('origin_rejected', { origin: origin ?? 'missing' }); + done(allowed, 403, 'Forbidden'); + }, }); this.ready = new Promise((resolve) => { this.#wss.on('listening', () => { @@ -48,15 +113,65 @@ export class Bridge { } #onConnection(socket: WebSocket): void { + if (this.#pendingConnections >= this.#maxPendingConnections) { + socket.close(1013, 'too many pending handshakes'); + return; + } + this.#pendingConnections += 1; + let awaitingHello = true; let session: Session | undefined; + let messageWindowStartedAt = this.#clock(); + let messagesInWindow = 0; + const releasePending = (): void => { + if (!awaitingHello) return; + awaitingHello = false; + this.#pendingConnections -= 1; + }; + const helloTimer = setTimeout(() => { + if (!awaitingHello) return; + releasePending(); + socket.close(1008, 'hello timeout'); + }, this.#helloTimeoutMs); socket.on('message', (raw) => { + const now = this.#clock(); + if (now - messageWindowStartedAt >= 1000) { + messageWindowStartedAt = now; + messagesInWindow = 0; + } + messagesInWindow += 1; + if (messagesInWindow > this.#maxMessagesPerSecond) { + log('message_rate_exceeded', {}); + socket.close(1008, 'message rate exceeded'); + return; + } + const parsed = this.#parse(rawToString(raw)); - if (parsed === null) return; + if (parsed === null) { + socket.close(1008, 'invalid message'); + return; + } if (parsed.kind === MessageKind.HELLO) { + if (session !== undefined) { + socket.close(1008, 'hello already received'); + return; + } + if (this.#token !== undefined && !tokensMatch(this.#token, parsed.token)) { + log('authentication_failed', {}); + socket.close(1008, 'authentication failed'); + return; + } + const existing = this.sessions.get(parsed.sessionId); + if (existing === undefined && this.sessions.count() >= this.#maxSessions) { + socket.close(1013, 'session limit reached'); + return; + } + clearTimeout(helloTimer); + releasePending(); session = new Session(parsed, socket, this.#clock); - this.sessions.add(session); + const replaced = this.sessions.add(session); + replaced?.disconnect('session replaced by a newer connection'); log('session_connected', { sessionId: session.id, url: session.url }); return; } @@ -71,9 +186,12 @@ export class Bridge { }); socket.on('close', () => { + clearTimeout(helloTimer); + releasePending(); if (session !== undefined) { - this.sessions.remove(session.id); - log('session_disconnected', { sessionId: session.id }); + if (this.sessions.remove(session)) { + log('session_disconnected', { sessionId: session.id }); + } } }); @@ -82,6 +200,14 @@ export class Bridge { }); } + #originAllowed(origin: string | undefined): boolean { + if (origin === undefined) return true; + const normalized = normalizeOrigin(origin); + if (normalized === null) return false; + if (this.#allowedOrigins.has(normalized)) return true; + return isLoopbackHostname(new URL(normalized).hostname); + } + #parse(text: string): ReturnType | null { let json: unknown; try { @@ -99,6 +225,7 @@ export class Bridge { close(): Promise { return new Promise((resolve) => { + for (const client of this.#wss.clients) client.terminate(); this.#wss.close(() => { resolve(); }); diff --git a/packages/server/src/crawl/crawl-tools.ts b/packages/server/src/crawl/crawl-tools.ts index 8dab03e..911f0e7 100644 --- a/packages/server/src/crawl/crawl-tools.ts +++ b/packages/server/src/crawl/crawl-tools.ts @@ -26,6 +26,12 @@ export const CRAWL_TOOLS: ToolDef[] = [ .string() .optional() .describe('CSS selector or element ref to restrict crawling to a subtree.'), + confirmDangerous: z + .boolean() + .optional() + .describe( + 'Set true to allow controls classified as destructive. Default false; those controls are blocked by the browser.', + ), sessionId: z .string() .optional() @@ -56,6 +62,7 @@ export const CRAWL_TOOLS: ToolDef[] = [ ...(maxSteps !== undefined ? { maxSteps } : {}), ...(settleMs !== undefined ? { settleMs } : {}), ...(scope !== undefined ? { scope } : {}), + ...(args['confirmDangerous'] === true ? { confirmDangerous: true } : {}), }; return crawl(session, opts, nodeSleep); }, diff --git a/packages/server/src/crawl/crawl.ts b/packages/server/src/crawl/crawl.ts index 0b08a29..d4a30e5 100644 --- a/packages/server/src/crawl/crawl.ts +++ b/packages/server/src/crawl/crawl.ts @@ -2,6 +2,7 @@ import { ActionType, CRAWL_DEFAULTS, CrawlAnomalyKind, + DANGEROUS_ACTION_CONFIRM_ARG, EventType, IrisCommand, type CommandResult, @@ -41,6 +42,7 @@ export interface CrawlOptions { maxSteps?: number; settleMs?: number; scope?: string; + confirmDangerous?: boolean; } /** Any buffer event that proves the app reacted to a click (vs a dead/no-op control). */ @@ -109,7 +111,7 @@ export async function crawl( const act = await session.command(IrisCommand.ACT, { ref: item.ref, action: ActionType.CLICK, - args: {}, + args: opts.confirmDangerous === true ? { [DANGEROUS_ACTION_CONFIRM_ARG]: true } : {}, }); await sleep(settleMs); const events = session.eventsSince(since); diff --git a/packages/server/src/events/predicate.test.ts b/packages/server/src/events/predicate.test.ts index 401cd6c..0d45770 100644 --- a/packages/server/src/events/predicate.test.ts +++ b/packages/server/src/events/predicate.test.ts @@ -7,7 +7,7 @@ import { type IrisEvent, type MatchResult, } from '@syrin/iris-protocol'; -import { evaluatePredicate, type PredicateSession } from './predicate.js'; +import { evaluatePredicate, waitForPredicate, type PredicateSession } from './predicate.js'; /** In-memory session: events from an array, MATCH from a supplied matcher. */ class FakeSession implements PredicateSession { @@ -203,4 +203,18 @@ describe('predicate engine', () => { expect(r.pass).toBe(false); expect(r.failureReason).toContain('Cancel'); }); + + it('turns a disconnected browser command into a failed wait verdict', async () => { + const session: PredicateSession = { + command: () => Promise.reject(new Error('session disconnected')), + eventsSince: () => [], + onEvent: () => () => undefined, + }; + const result = await waitForPredicate( + session, + { kind: 'element', query: { text: 'Ready' } }, + 100, + ); + expect(result).toEqual({ pass: false, failureReason: 'session disconnected' }); + }); }); diff --git a/packages/server/src/events/predicate.ts b/packages/server/src/events/predicate.ts index dfa60d9..6990d7b 100644 --- a/packages/server/src/events/predicate.ts +++ b/packages/server/src/events/predicate.ts @@ -391,6 +391,10 @@ export function waitForPredicate( ): Promise { return new Promise((resolve) => { let done = false; + const failed = (error: unknown): EvalResult => ({ + pass: false, + failureReason: error instanceof Error ? error.message : String(error), + }); const finish = (result: EvalResult): void => { if (done) return; done = true; @@ -400,22 +404,30 @@ export function waitForPredicate( resolve(result); }; const check = (): void => { - void evaluatePredicate(session, predicate, since).then((r) => { - if (r.pass) finish(r); - }); + void evaluatePredicate(session, predicate, since) + .then((r) => { + if (r.pass) finish(r); + }) + .catch((error: unknown) => { + finish(failed(error)); + }); }; const unsub = session.onEvent(() => { check(); }); const interval = setInterval(check, 150); const timer = setTimeout(() => { - void evaluatePredicate(session, predicate, since).then((r) => { - finish({ - pass: false, - evidence: r.evidence, - failureReason: r.failureReason ?? 'timed out waiting for predicate', + void evaluatePredicate(session, predicate, since) + .then((r) => { + finish({ + pass: false, + evidence: r.evidence, + failureReason: r.failureReason ?? 'timed out waiting for predicate', + }); + }) + .catch((error: unknown) => { + finish(failed(error)); }); - }); }, timeoutMs); check(); }); diff --git a/packages/server/src/events/ring-buffer.test.ts b/packages/server/src/events/ring-buffer.test.ts index 7c2e77f..5ba0e1d 100644 --- a/packages/server/src/events/ring-buffer.test.ts +++ b/packages/server/src/events/ring-buffer.test.ts @@ -23,6 +23,14 @@ describe('RingBuffer', () => { expect(buf.since(0).map((e) => e.t)).toEqual([2, 3]); }); + it('evicts by serialized byte size', () => { + const buf = new RingBuffer({ maxAgeMs: 1_000_000, maxEvents: 100, maxBytes: 300 }); + buf.push({ ...ev(1), data: { text: 'a'.repeat(100) } }, 1); + buf.push({ ...ev(2), data: { text: 'b'.repeat(100) } }, 2); + expect(buf.since(0).map((e) => e.t)).toEqual([2]); + expect(buf.bufferHealth().dropped).toBe(1); + }); + it('since() and window() select the right slices', () => { const buf = new RingBuffer({ maxAgeMs: 1_000_000, maxEvents: 100 }); [10, 20, 30, 40].forEach((t) => buf.push(ev(t), t)); diff --git a/packages/server/src/events/ring-buffer.ts b/packages/server/src/events/ring-buffer.ts index 76fa236..e5c5964 100644 --- a/packages/server/src/events/ring-buffer.ts +++ b/packages/server/src/events/ring-buffer.ts @@ -3,6 +3,7 @@ import { RING_BUFFER_DEFAULTS, type IrisEvent } from '@syrin/iris-protocol'; export interface RingBufferOptions { maxEvents?: number; maxAgeMs?: number; + maxBytes?: number; } /** @@ -16,16 +17,23 @@ export interface RingBufferOptions { export class RingBuffer { readonly #maxEvents: number; readonly #maxAgeMs: number; + readonly #maxBytes: number; #events: IrisEvent[] = []; + #eventBytes: number[] = []; + #totalBytes = 0; #droppedCount = 0; constructor(options: RingBufferOptions = {}) { this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS; this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS; + this.#maxBytes = options.maxBytes ?? RING_BUFFER_DEFAULTS.MAX_BYTES; } push(event: IrisEvent, now: number): void { this.#events.push(event); + const bytes = Buffer.byteLength(JSON.stringify(event), 'utf8'); + this.#eventBytes.push(bytes); + this.#totalBytes += bytes; this.#evict(now); } @@ -53,10 +61,17 @@ export class RingBuffer { #evict(now: number): void { const before = this.#events.length; const cutoff = now - this.#maxAgeMs; - if (this.#events.length > this.#maxEvents) { - this.#events = this.#events.slice(this.#events.length - this.#maxEvents); + while ( + this.#events.length > this.#maxEvents || + (this.#totalBytes > this.#maxBytes && this.#events.length > 0) + ) { + this.#events.shift(); + this.#totalBytes -= this.#eventBytes.shift() ?? 0; + } + while ((this.#events[0]?.t ?? cutoff) < cutoff) { + this.#events.shift(); + this.#totalBytes -= this.#eventBytes.shift() ?? 0; } - this.#events = this.#events.filter((e) => e.t >= cutoff); this.#droppedCount += before - this.#events.length; } diff --git a/packages/server/src/flows/flow-replay.test.ts b/packages/server/src/flows/flow-replay.test.ts index 7b0d497..105c585 100644 --- a/packages/server/src/flows/flow-replay.test.ts +++ b/packages/server/src/flows/flow-replay.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ActionType, AnchorKind, + DANGEROUS_ACTION_CONFIRM_ARG, DriftReason, EventType, FLOW_FILE_VERSION, @@ -15,7 +16,7 @@ import { } from '@syrin/iris-protocol'; import { nearestTestid, replayFlow, type FlowReplaySession } from './flow-replay.js'; import { waitForPredicate, type Predicate } from '../events/predicate.js'; -import { asString } from '../tools/tools-helpers.js'; +import { asRecord, asString } from '../tools/tools-helpers.js'; import { IrisTool } from '../tools/tool-names.js'; /** A scripted QUERY response per testid: live elements + (on zero) the present-testids near-miss. */ @@ -33,6 +34,7 @@ const PASS = (): boolean => true; */ class FakeSession implements FlowReplaySession { readonly acts: { ref: string; action: string }[] = []; + readonly actArgs: Record[] = []; readonly queries: string[] = []; constructor( @@ -55,6 +57,7 @@ class FakeSession implements FlowReplaySession { if (name === IrisCommand.ACT) { const ref = asString(args['ref']) ?? ''; this.acts.push({ ref, action: asString(args['action']) ?? '' }); + this.actArgs.push(asRecord(args['args'])); return Promise.resolve({ kind: 'command_result', id: 'a', @@ -168,6 +171,20 @@ describe('replayFlow — anchor re-resolution + legible drift', () => { expect(session.acts).toEqual([{ ref: 'e1', action: ActionType.CLICK }]); }); + it('requires a fresh destructive-action confirmation for each replay', async () => { + const step = testidStep('delete-account'); + step.args = { [DANGEROUS_ACTION_CONFIRM_ARG]: true }; + const script = (testid: string): QueryScript => ({ elements: [el(`e-${testid}`, testid)] }); + + const unconfirmed = new FakeSession(script); + await replayFlow(unconfirmed, flow([step]), waitForPredicate, FAST); + expect(unconfirmed.actArgs[0]).not.toHaveProperty(DANGEROUS_ACTION_CONFIRM_ARG); + + const confirmed = new FakeSession(script); + await replayFlow(confirmed, flow([step]), waitForPredicate, FAST, true); + expect(confirmed.actArgs[0]).toMatchObject({ [DANGEROUS_ACTION_CONFIRM_ARG]: true }); + }); + it('6: a signal-expect step waits on the signal predicate (reuses predicate.ts)', async () => { const session = new FakeSession(() => ({ elements: [] }), [signalEvent('order-placed')]); const waitSpy = vi.fn(waitForPredicate); diff --git a/packages/server/src/flows/flow-replay.ts b/packages/server/src/flows/flow-replay.ts index 603ea30..20b2daa 100644 --- a/packages/server/src/flows/flow-replay.ts +++ b/packages/server/src/flows/flow-replay.ts @@ -14,6 +14,7 @@ import { } from '@syrin/iris-protocol'; import type { EvalResult, Predicate } from '../events/predicate.js'; import { asRecord, asString } from '../tools/tools-helpers.js'; +import { replayActionArgs } from './replay.js'; /** * The session surface flow-replay needs: QUERY to re-resolve a testid anchor against the live @@ -131,6 +132,7 @@ async function runTestidStep( index: number, value: string, dynamic: ReadonlySet, + confirmDangerous: boolean, ): Promise { const queryResult = await session.command(IrisCommand.QUERY, { by: QueryBy.TESTID, value }); const { refs, hint } = readQuery(queryResult); @@ -148,7 +150,7 @@ async function runTestidStep( const act = await session.command(IrisCommand.ACT, { ref, action: step.action ?? '', - args: step.args ?? {}, + args: replayActionArgs(step.args, confirmDangerous), }); const result: FlowStepResult = { step: index, tool: step.tool, anchor: value, ok: act.ok }; if (!act.ok) { @@ -216,6 +218,7 @@ export async function replayFlow( flow: FlowFile, waitForSignal: WaitForSignal, signalTimeoutMs: number, + confirmDangerous = false, ): Promise { const results: FlowStepResult[] = []; // testids whose region is LLM-dynamic — their expect-presence is NOT asserted. @@ -231,7 +234,7 @@ export async function replayFlow( if (step.anchor.kind === AnchorKind.SIGNAL) { result = await runSignalStep(session, step, index, label, waitForSignal, signalTimeoutMs); } else { - result = await runTestidStep(session, step, index, label, dynamic); + result = await runTestidStep(session, step, index, label, dynamic, confirmDangerous); } results.push(result); if (result.drift !== undefined || !result.ok) break; diff --git a/packages/server/src/flows/flow-tools.ts b/packages/server/src/flows/flow-tools.ts index 820bd48..dbac2c7 100644 --- a/packages/server/src/flows/flow-tools.ts +++ b/packages/server/src/flows/flow-tools.ts @@ -165,12 +165,16 @@ export const FLOW_TOOLS: ToolDef[] = [ 'anchor (testid via iris_query; signal via predicate) against the LIVE DOM — never reuses a ' + 'stale ref. On an anchor MISS returns legible DRIFT { step, anchor, drift:{ reasonKind, reason, ' + 'nearest } } (the closest surviving testid) and stops — the "whose fault is it" contract. ' + - 'Returns { name, status: ok|drift|error, steps:[...] }; a missing/malformed file is status:error ' + - 'with a structured code (distinct from a contract-changed drift).', + 'Returns { name, status: ok|drift|error, steps:[...] }; missing/malformed files and action ' + + 'failures are status:error with a structured code (distinct from contract-changed drift).', inputSchema: { flowName: z .string() .describe('Flow file name (without .json extension) from iris_flow_list.'), + confirmDangerous: z + .boolean() + .optional() + .describe('Set true to allow destructive controls during this replay only.'), sessionId: z .string() .optional() @@ -205,12 +209,23 @@ export const FLOW_TOOLS: ToolDef[] = [ loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS, + args['confirmDangerous'] === true, ); const driftSteps = steps.filter((s) => s.drift !== undefined).length; const allOk = steps.every((s) => s.ok); const status = - driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.DRIFT; + driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR; await recordReplayRun(deps, name, status, driftSteps, deps.now() - startedAt); + const failed = steps.find((step) => !step.ok && step.drift === undefined); + if (failed !== undefined) { + const message = failed.error ?? 'flow action failed'; + return { + name, + status, + steps, + error: { code: ReplayStatus.ERROR, message }, + }; + } return { name, status, steps }; }, }, @@ -275,6 +290,10 @@ export const FLOW_TOOLS: ToolDef[] = [ inputSchema: { flowName: z.string().describe('Flow file name to heal (from iris_flow_list).'), apply: z.boolean().optional(), + confirmDangerous: z + .boolean() + .optional() + .describe('Set true to allow destructive controls during this heal replay only.'), sessionId: z .string() .optional() @@ -329,8 +348,27 @@ async function healFlow(deps: ToolDeps, args: Record): Promise< } const session = deps.sessions.resolve(asString(args['sessionId'])); - const steps = await replayFlow(session, loaded.value, waitForPredicate, FLOW_SIGNAL_TIMEOUT_MS); + const steps = await replayFlow( + session, + loaded.value, + waitForPredicate, + FLOW_SIGNAL_TIMEOUT_MS, + args['confirmDangerous'] === true, + ); const drifted = steps.some((s) => s.drift !== undefined); + const failed = steps.find((s) => !s.ok && s.drift === undefined); + if (failed !== undefined) { + const message = failed.error ?? 'flow replay failed before an anchor could be healed'; + return { + name, + status: HealStatus.ERROR, + applied: false, + proposals: [], + changed: [], + message, + error: { code: ReplayStatus.ERROR, message }, + }; + } if (!drifted) { return { name, diff --git a/packages/server/src/flows/flows.heal.test.ts b/packages/server/src/flows/flows.heal.test.ts index 0061882..d1346a4 100644 --- a/packages/server/src/flows/flows.heal.test.ts +++ b/packages/server/src/flows/flows.heal.test.ts @@ -5,12 +5,14 @@ import { join } from 'node:path'; import { ActionType, AnchorKind, + DANGEROUS_ACTION_CONFIRM_ARG, FLOW_FILE_VERSION, FLOW_SIGNAL_TIMEOUT_MS, FlowErrorCode, FlowFileSchema, HealStatus, IrisCommand, + ReplayStatus, type CommandResult, type ElementDescriptor, type FlowFile, @@ -51,7 +53,12 @@ function present(testids: string[]): QueryEmptyHint { } class FakeSession { - constructor(private readonly script: (testid: string) => QueryScript) {} + readonly actArgs: Record[] = []; + + constructor( + private readonly script: (testid: string) => QueryScript, + private readonly actOk = true, + ) {} command(name: string, args: Record = {}): Promise { if (name === IrisCommand.QUERY) { return Promise.resolve({ @@ -61,6 +68,16 @@ class FakeSession { result: this.script(asString(args['value']) ?? ''), }); } + if (name === IrisCommand.ACT) { + this.actArgs.push((args['args'] as Record | undefined) ?? {}); + return Promise.resolve({ + kind: 'command_result', + id: 'a', + ok: this.actOk, + result: {}, + ...(this.actOk ? {} : { error: 'act failed' }), + }); + } return Promise.resolve({ kind: 'command_result', id: 'x', ok: true, result: {} }); } eventsSince(): IrisEvent[] { @@ -204,6 +221,33 @@ describe('FlowStore.heal + iris_flow_heal', () => { expect(after).toEqual(before); }); + it('heal reports a resolved-anchor action failure as an error', async () => { + await store.saveFlow(flowFile('chat', [clickStep('chat-send')])); + const session = new FakeSession((testid) => ({ elements: [el(`e-${testid}`, testid)] }), false); + + const res = await heal(store, session, { flowName: 'chat', apply: true }); + expect(res.status).toBe(HealStatus.ERROR); + expect(res.error).toEqual({ code: ReplayStatus.ERROR, message: 'act failed' }); + expect(res.applied).toBe(false); + expect(res.proposals).toEqual([]); + expect(res.changed).toEqual([]); + }); + + it('heal forwards destructive-action confirmation only when explicitly requested', async () => { + await store.saveFlow(flowFile('chat', [clickStep('delete-account')])); + const session = new FakeSession((testid) => ({ elements: [el(`e-${testid}`, testid)] })); + + await heal(store, session, { flowName: 'chat', apply: false }); + await heal(store, session, { + flowName: 'chat', + apply: false, + confirmDangerous: true, + }); + + expect(session.actArgs[0]).not.toHaveProperty(DANGEROUS_ACTION_CONFIRM_ARG); + expect(session.actArgs[1]).toMatchObject({ [DANGEROUS_ACTION_CONFIRM_ARG]: true }); + }); + it('heal on a missing flow returns a structured error', async () => { const session = new FakeSession(() => ({ elements: [] })); const res = await heal(store, session, { flowName: 'ghost', apply: true }); diff --git a/packages/server/src/flows/recordings.test.ts b/packages/server/src/flows/recordings.test.ts index eb38ce0..2401fd2 100644 --- a/packages/server/src/flows/recordings.test.ts +++ b/packages/server/src/flows/recordings.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { ActionType, DANGEROUS_ACTION_CONFIRM_ARG } from '@syrin/iris-protocol'; import { RecordingStore, type RecordedStep, type CompiledProgram } from './recordings.js'; +import { compileActStep, compileSequenceStep } from './replay.js'; const step = (tool: string, stable = true): RecordedStep => ({ tool, stable, args: {} }); @@ -41,4 +43,30 @@ describe('RecordingStore (G6)', () => { expect(store.getCompiled('flow')).toBe(program); expect(store.getCompiled('nope')).toBeUndefined(); }); + + it('never persists one-shot destructive-action confirmations', () => { + const act = compileActStep( + { + ref: 'e1', + action: ActionType.CLICK, + args: { [DANGEROUS_ACTION_CONFIRM_ARG]: true, value: 'kept' }, + }, + { testid: 'delete-account' }, + ); + expect(act.args['args']).toEqual({ value: 'kept' }); + + const sequence = compileSequenceStep( + { + steps: [ + { + ref: 'e1', + action: ActionType.CLICK, + args: { [DANGEROUS_ACTION_CONFIRM_ARG]: true }, + }, + ], + }, + { steps: [{ testid: 'delete-account' }] }, + ); + expect((sequence.args['steps'] as { args: Record }[])[0]?.args).toEqual({}); + }); }); diff --git a/packages/server/src/flows/replay.ts b/packages/server/src/flows/replay.ts index 7cba7ca..e950529 100644 --- a/packages/server/src/flows/replay.ts +++ b/packages/server/src/flows/replay.ts @@ -1,4 +1,4 @@ -import { IrisCommand, QueryBy } from '@syrin/iris-protocol'; +import { DANGEROUS_ACTION_CONFIRM_ARG, IrisCommand, QueryBy } from '@syrin/iris-protocol'; import { IrisTool } from '../tools/tool-names.js'; import type { RecordedStep, CompiledProgram } from './recordings.js'; import type { Session } from '../session/session.js'; @@ -11,11 +11,22 @@ function asRecord(value: unknown): Record { return typeof value === 'object' && value !== null ? (value as Record) : {}; } +/** A destructive-action confirmation is one-shot and must never persist into a recording. */ +export function replayActionArgs( + value: unknown, + confirmDangerous = false, +): Record { + const args = { ...asRecord(value) }; + delete args[DANGEROUS_ACTION_CONFIRM_ARG]; + if (confirmDangerous) args[DANGEROUS_ACTION_CONFIRM_ARG] = true; + return args; +} + /** Compile a single iris_act invocation into a normalized RecordedStep using the action result's testid. */ export function compileActStep(args: Record, res: unknown): RecordedStep { const testid = asString(asRecord(res)['testid']); const action = asString(args['action']) ?? ''; - const actArgs = asRecord(args['args']); + const actArgs = replayActionArgs(args['args']); if (testid !== undefined) { return { tool: IrisTool.ACT, @@ -48,7 +59,7 @@ export function compileSequenceStep(args: Record, res: unknown) const subSteps: CompiledSubStep[] = inputSteps.map((raw, i) => { const step = asRecord(raw); const action = asString(step['action']) ?? ''; - const stepArgs = asRecord(step['args']); + const stepArgs = replayActionArgs(step['args']); const testid = asString(asRecord(resolved[i])['testid']); if (testid !== undefined) { return { by: QueryBy.TESTID, value: testid, action, args: stepArgs }; @@ -95,6 +106,7 @@ export interface ReplayStepResult { export async function replayProgram( session: Session, program: CompiledProgram, + confirmDangerous = false, ): Promise { const results: ReplayStepResult[] = []; for (const step of program.steps) { @@ -110,7 +122,7 @@ export async function replayProgram( liveSteps.push({ ref, action: asString(sub['action']) ?? '', - args: asRecord(sub['args']), + args: replayActionArgs(sub['args'], confirmDangerous), }); } const r = await session.command(IrisCommand.ACT_SEQUENCE, { steps: liveSteps }); @@ -121,7 +133,7 @@ export async function replayProgram( const r = await session.command(IrisCommand.ACT, { ref, action: asString(step.args['action']) ?? '', - args: asRecord(step.args['args']), + args: replayActionArgs(step.args['args'], confirmDangerous), }); results.push(buildResult(step.tool, r.ok, r.error, note !== undefined ? [note] : [])); if (!r.ok) break; diff --git a/packages/server/src/flows/tools.flow-replay.test.ts b/packages/server/src/flows/tools.flow-replay.test.ts index 501e170..2079b52 100644 --- a/packages/server/src/flows/tools.flow-replay.test.ts +++ b/packages/server/src/flows/tools.flow-replay.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ActionType, + DANGEROUS_ACTION_CONFIRM_ARG, FlowErrorCode, IrisCommand, QueryBy, @@ -22,14 +23,22 @@ import { ProjectStore } from '../project/project-store.js'; import { AnnotationStore } from './annotation-store.js'; import { createNodeFileSystem, type FileSystemPort } from '../project/fs-port.js'; import { flowPath } from '../project/iris-dir.js'; -import { asString } from '../tools/tools-helpers.js'; +import { asRecord, asString } from '../tools/tools-helpers.js'; import type { Session, SessionManager } from '../session/session.js'; import type { CompiledProgram, RecordedStep } from './recordings.js'; const clock = { now: (): number => 1234 }; -/** A session whose QUERY answers from a per-testid script and whose ACT always succeeds. */ -function scriptedSession(queryScript: (testid: string) => unknown): Partial { +interface ScriptedSessionOptions { + actOk?: boolean; + actArgs?: Record[]; +} + +/** A session whose QUERY answers from a per-testid script and whose ACT is configurable. */ +function scriptedSession( + queryScript: (testid: string) => unknown, + options: ScriptedSessionOptions = {}, +): Partial { const command = (name: string, args: Record = {}): Promise => { if (name === IrisCommand.QUERY) { return Promise.resolve({ @@ -39,7 +48,18 @@ function scriptedSession(queryScript: (testid: string) => unknown): Partial [], onEvent: () => () => undefined }; } @@ -203,4 +223,41 @@ describe('iris_flow_replay handler — temp dir, never touches the repo', () => expect(last?.status).toBe(RunStatus.ERROR); expect(last?.kind).toBe(RunKind.FLOW_REPLAY); }); + + it('H: an action failure is an error, not selector drift', async () => { + await saveFlow('action-fails', [actStep('chat-send')]); + const session = scriptedSession((testid) => ({ elements: [{ ref: `e-${testid}` }] }), { + actOk: false, + }); + const deps = fakeDeps(fs, root, session); + + const res = (await tool(IrisTool.FLOW_REPLAY).handler(deps, { + flowName: 'action-fails', + })) as FlowReplayResult; + expect(res.status).toBe(ReplayStatus.ERROR); + expect(res.steps[0]).toMatchObject({ ok: false, error: 'act failed' }); + expect(res.error).toEqual({ code: ReplayStatus.ERROR, message: 'act failed' }); + + const last = await deps.project.lastRun('action-fails'); + expect(last?.status).toBe(RunStatus.ERROR); + expect(last?.evidence).toMatchObject({ driftSteps: 0 }); + }); + + it('I: destructive-action confirmation is scoped to the current replay invocation', async () => { + await saveFlow('dangerous', [actStep('delete-account')]); + const actArgs: Record[] = []; + const session = scriptedSession((testid) => ({ elements: [{ ref: `e-${testid}` }] }), { + actArgs, + }); + const deps = fakeDeps(fs, root, session); + + await tool(IrisTool.FLOW_REPLAY).handler(deps, { flowName: 'dangerous' }); + await tool(IrisTool.FLOW_REPLAY).handler(deps, { + flowName: 'dangerous', + confirmDangerous: true, + }); + + expect(actArgs[0]).not.toHaveProperty(DANGEROUS_ACTION_CONFIRM_ARG); + expect(actArgs[1]).toMatchObject({ [DANGEROUS_ACTION_CONFIRM_ARG]: true }); + }); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 96addec..4154c50 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -85,6 +85,12 @@ export type { export interface StartOptions { port?: number; + /** Bind address. Non-loopback hosts require a token. Defaults to IRIS_HOST or localhost. */ + host?: string; + /** Browser/bridge pairing token. Defaults to IRIS_TOKEN. */ + token?: string; + /** Browser origins allowed in addition to localhost. Defaults to IRIS_ALLOWED_ORIGINS. */ + allowedOrigins?: string[]; /** When false, skip the MCP stdio transport (used in tests). */ mcp?: boolean; /** CDP endpoint for native real-input mode. Defaults to env IRIS_CDP_URL. No-op if unset. */ @@ -113,7 +119,23 @@ export interface RunningServer { /** Start the Iris bridge (browser WS endpoint) and, by default, the MCP stdio server. */ export async function start(options: StartOptions = {}): Promise { const port = options.port ?? IRIS_DEFAULT_PORT; - const bridge = new Bridge({ port }); + const envToken = process.env['IRIS_TOKEN']; + const envOrigins = process.env['IRIS_ALLOWED_ORIGINS']; + const host = options.host ?? process.env['IRIS_HOST']; + const token = + options.token ?? (envToken !== undefined && envToken.length > 0 ? envToken : undefined); + const allowedOrigins = + options.allowedOrigins ?? + envOrigins + ?.split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + const bridge = new Bridge({ + port, + ...(host === undefined ? {} : { host }), + ...(token === undefined ? {} : { token }), + ...(allowedOrigins === undefined ? {} : { allowedOrigins }), + }); // Server-authoritative liveness: a Node-side reaper (immune to browser throttling) ends sessions // whose agent has gone idle, so a forgotten/crashed agent never leaves the HUD "running" forever. const reaper = new SessionReaper(bridge.sessions); diff --git a/packages/server/src/input/tools.real-input.test.ts b/packages/server/src/input/tools.real-input.test.ts index 75f0717..8931b4c 100644 --- a/packages/server/src/input/tools.real-input.test.ts +++ b/packages/server/src/input/tools.real-input.test.ts @@ -19,6 +19,7 @@ const TARGET_BOX: ElementBox = { x: 400, y: 200, width: 40, height: 20 }; interface FakeSessionState { actCalls: number; inspectRefs: string[]; + inspectName?: string; /** When set, INSPECT for this ref returns no box (stale ref). */ staleRef?: string; /** When set, INSPECT returns a zero-area box for this ref. */ @@ -42,7 +43,12 @@ function fakeSession(state: FakeSessionState): Session { }); } const box = ref === 'eTarget' ? TARGET_BOX : SOURCE_BOX; - return Promise.resolve({ kind: 'command_result', id: 'c', ok: true, result: { box } }); + return Promise.resolve({ + kind: 'command_result', + id: 'c', + ok: true, + result: { box, ...(state.inspectName === undefined ? {} : { name: state.inspectName }) }, + }); } if (name === 'act') { state.actCalls += 1; @@ -160,6 +166,30 @@ describe('iris_act real-input routing', () => { expect(provider.calls[0]?.center).toEqual({ cx: 100, cy: 50 }); }); + it('blocks a destructive native click until explicitly confirmed', async () => { + const provider = makeProvider(true); + const state: FakeSessionState = { + actCalls: 0, + inspectRefs: [], + inspectName: 'Delete account', + }; + await expect( + runAct(fakeDeps(provider, state), { + ref: 'e1', + action: 'click', + args: { native: true }, + }), + ).rejects.toThrow(/confirmDangerous/); + expect(provider.calls).toHaveLength(0); + + await runAct(fakeDeps(provider, state), { + ref: 'e1', + action: 'click', + args: { native: true, confirmDangerous: true }, + }); + expect(provider.calls).toHaveLength(1); + }); + it('routes hover to real input with the hover action', async () => { const provider = makeProvider(true); const state: FakeSessionState = { actCalls: 0, inspectRefs: [] }; diff --git a/packages/server/src/project/project-store.test.ts b/packages/server/src/project/project-store.test.ts index 3c80cff..43084b5 100644 --- a/packages/server/src/project/project-store.test.ts +++ b/packages/server/src/project/project-store.test.ts @@ -110,7 +110,7 @@ describe('ProjectStore — temp-dir filesystem, never touches the repo', () => { if (!r.ok) throw new Error('expected ok'); expect(r.file.runs).toHaveLength(PROJECT_RUN_CAP.TOTAL); expect(r.file.runs.at(-1)?.name).toBe(`flow-${PROJECT_RUN_CAP.TOTAL + 24}`); - }); + }, 30_000); // ---- EDGE / INVALID ---- diff --git a/packages/server/src/session/session.ts b/packages/server/src/session/session.ts index bc539f5..65859d0 100644 --- a/packages/server/src/session/session.ts +++ b/packages/server/src/session/session.ts @@ -309,6 +309,16 @@ export class Session { } } + /** End this transport without letting a stale socket remove its replacement session. */ + disconnect(reason: string): void { + this.rejectAll(reason); + try { + this.#socket.close(1008, reason); + } catch { + // A fake or already-closed socket needs no further cleanup. + } + } + // ── Live-control: state machine + human→agent inbox (server-owned) ─────────────── getState(): SessionState { @@ -440,13 +450,16 @@ export class Session { export class SessionManager { readonly #sessions = new Map(); - add(session: Session): void { + add(session: Session): Session | undefined { + const previous = this.#sessions.get(session.id); this.#sessions.set(session.id, session); + return previous; } - remove(sessionId: string): void { - this.#sessions.get(sessionId)?.rejectAll('session disconnected'); - this.#sessions.delete(sessionId); + remove(session: Session): boolean { + if (this.#sessions.get(session.id) !== session) return false; + session.rejectAll('session disconnected'); + return this.#sessions.delete(session.id); } get(sessionId: string): Session | undefined { diff --git a/packages/server/src/tools/browser-tools.ts b/packages/server/src/tools/browser-tools.ts index 0e1beac..a6aa6f7 100644 --- a/packages/server/src/tools/browser-tools.ts +++ b/packages/server/src/tools/browser-tools.ts @@ -40,8 +40,17 @@ export const BROWSER_TOOLS: ToolDef[] = [ handler: async (deps, args) => { const url = asString(args['url']); if (url === undefined || url.length === 0) return { ok: false, reason: 'url required' }; - await commandOrThrow(deps, asString(args['sessionId']), IrisCommand.NAVIGATE, { url }); - return { ok: true, url }; + const result = (await commandOrThrow( + deps, + asString(args['sessionId']), + IrisCommand.NAVIGATE, + { url }, + )) as { ok?: unknown; url?: unknown; reason?: unknown }; + return { + ok: result.ok === true, + ...(typeof result.url === 'string' ? { url: result.url } : {}), + ...(typeof result.reason === 'string' ? { reason: result.reason } : {}), + }; }, }, { diff --git a/packages/server/src/tools/tools.ts b/packages/server/src/tools/tools.ts index ace1560..27f3c2a 100644 --- a/packages/server/src/tools/tools.ts +++ b/packages/server/src/tools/tools.ts @@ -2,10 +2,12 @@ import { z } from 'zod'; import { ActionType, ActionWarning, + DANGEROUS_ACTION_CONFIRM_ARG, EventType, InputMode, InputModeReason, IrisCommand, + isDangerousActionText, SnapshotMode, } from '@syrin/iris-protocol'; import type { Session, SessionManager } from '../session/session.js'; @@ -175,14 +177,49 @@ async function tryRealInput( if (!(await provider.isAvailableFor(session.url))) return synthetic(InputModeReason.PAGE_NOT_CORRELATED); - const box = asBox(await commandOrThrow(deps, session.id, IrisCommand.INSPECT, { ref })); + const inspected = await commandOrThrow(deps, session.id, IrisCommand.INSPECT, { ref }); + const confirmed = inner[DANGEROUS_ACTION_CONFIRM_ARG] === true; + const dangerousDescriptorText = (value: unknown): string => { + const descriptor = asRecord(value); + return [ + asString(descriptor['name']) ?? '', + asString(descriptor['text']) ?? '', + asString(descriptor['value']) ?? '', + asString(descriptor['href']) ?? '', + asString(descriptor['formAction']) ?? '', + asString(descriptor['formText']) ?? '', + ].join(' '); + }; + if ( + (action === ActionType.CLICK || action === ActionType.DBLCLICK) && + !confirmed && + isDangerousActionText(dangerousDescriptorText(inspected)) + ) { + throw new Error( + `potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`, + ); + } + const box = asBox(inspected); if (box === undefined) return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE); let toBox: ElementBox | undefined; if (action === ActionType.DRAG) { const toRef = asString(inner['toRef']); if (toRef === undefined) return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED); - toBox = asBox(await commandOrThrow(deps, session.id, IrisCommand.INSPECT, { ref: toRef })); + const targetInspected = await commandOrThrow(deps, session.id, IrisCommand.INSPECT, { + ref: toRef, + }); + if ( + !confirmed && + isDangerousActionText( + `${dangerousDescriptorText(inspected)} ${dangerousDescriptorText(targetInspected)}`, + ) + ) { + throw new Error( + `potentially destructive native action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`, + ); + } + toBox = asBox(targetInspected); if (toBox === undefined) return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED); } @@ -367,7 +404,7 @@ export const TOOLS: ToolDef[] = [ .record(z.unknown()) .optional() .describe( - 'Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click.', + 'Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { confirmDangerous: true } to allow a potentially destructive control.', ), refuseWhenThrottled: z .boolean() @@ -452,7 +489,7 @@ export const TOOLS: ToolDef[] = [ steps: z .array(z.record(z.unknown())) .describe( - 'Ordered list of { ref, action, args? } objects. Each step is equivalent to one iris_act call.', + 'Ordered list of { ref, action, args? } objects. Each step is equivalent to one iris_act call; put confirmDangerous:true in a destructive step args object.', ), ...sessionIdShape, }, @@ -502,7 +539,9 @@ export const TOOLS: ToolDef[] = [ args: z .record(z.unknown()) .optional() - .describe('Action-specific arguments: { value } for fill/select, { text } for type/press.'), + .describe( + 'Action-specific arguments: { value } for fill/select, { text } for type/press, { confirmDangerous: true } for a potentially destructive control.', + ), until: PredicateSchema.describe( 'Predicate to wait for after the action completes. Same shape accepted by iris_assert.', ), @@ -932,11 +971,15 @@ export const TOOLS: ToolDef[] = [ { name: IrisTool.REPLAY, description: - 'Re-execute a previously recorded program by recordingName. Re-resolves each step to its element by testid (falling back to the stored ref for unstable steps) and runs the actions in order against the live session. Stops at the first failure. Returns { ok, steps:[{tool,ok,error?,note?}] }.', + 'Re-execute a previously recorded program by recordingName. Re-resolves each step to its element by testid (falling back to the stored ref for unstable steps) and runs the actions in order against the live session. Stops at the first failure. Destructive controls require confirmDangerous:true on every replay; confirmation is never persisted. Returns { ok, steps:[{tool,ok,error?,note?}] }.', inputSchema: { recordingName: z .string() .describe('Name of a compiled recording (from iris_record_stop) to re-execute.'), + confirmDangerous: z + .boolean() + .optional() + .describe('Set true to allow destructive controls during this replay only.'), ...sessionIdShape, }, outputSchema: { @@ -957,7 +1000,7 @@ export const TOOLS: ToolDef[] = [ if (program === undefined) throw new Error(`no compiled recording named '${name}'`); const session = deps.sessions.resolve(asString(args['sessionId'])); const since = session.elapsed(); - const steps = await replayProgram(session, program); + const steps = await replayProgram(session, program, args['confirmDangerous'] === true); return { recordingName: name, since, steps, ok: steps.every((s) => s.ok) }; }, }, diff --git a/packages/test/package.json b/packages/test/package.json index 4544fb4..3cae042 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -15,7 +15,7 @@ "build": "tsc -b", "typecheck": "tsc -b", "lint": "eslint src", - "test:unit": "vitest run --passWithNoTests", + "test:unit": "vitest run src --passWithNoTests", "prepack": "tsc -b && find dist -name \"*.test.*\" -delete" }, "dependencies": { @@ -23,7 +23,7 @@ "@syrin/iris-server": "workspace:*" }, "peerDependencies": { - "vitest": "^3.0.0" + "vitest": "^3.2.6" }, "peerDependenciesMeta": { "vitest": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b70a85e..8b8b550 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + esbuild@>=0.18.0 <0.28.1: 0.28.1 + postcss@<8.5.10: 8.5.15 + importers: .: @@ -15,8 +19,8 @@ importers: specifier: ^22.10.0 version: 22.19.20 '@vitest/coverage-v8': - specifier: ^4.1.8 - version: 4.1.8(vitest@3.2.6(@types/node@22.19.20)(jsdom@29.1.1)) + specifier: ^3.2.6 + version: 3.2.6(vitest@3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0)) eslint: specifier: ^9.18.0 version: 9.39.4 @@ -42,8 +46,8 @@ importers: specifier: ^8.20.0 version: 8.60.1(eslint@9.39.4)(typescript@5.9.3) vitest: - specifier: ^3.0.0 - version: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1) + specifier: ^3.2.6 + version: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0) ws: specifier: ^8.21.0 version: 8.21.0 @@ -75,9 +79,18 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7) devDependencies: + '@babel/core': + specifier: ^7.29.7 + version: 7.29.7 + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)) '@syrin/iris-babel-plugin': specifier: workspace:* version: link:../../packages/babel-plugin + '@types/babel__core': + specifier: ^7.20.5 + version: 7.20.5 '@types/react': specifier: ^19.0.0 version: 19.2.17 @@ -85,8 +98,8 @@ importers: specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.3(@types/node@22.19.20)) + specifier: ^6.0.2 + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)) agentation: specifier: ^3.0.2 version: 3.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -94,8 +107,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 vite: - specifier: ^6.0.7 - version: 6.4.3(@types/node@22.19.20) + specifier: ^8.0.16 + version: 8.0.16(@types/node@22.19.20)(esbuild@0.28.1) apps/e2e: dependencies: @@ -205,8 +218,8 @@ importers: specifier: '>=18' version: 19.2.7 vitest: - specifier: ^3.0.0 - version: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1) + specifier: ^3.2.6 + version: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0) ws: specifier: ^8.18.0 version: 8.21.0 @@ -336,11 +349,15 @@ importers: specifier: workspace:* version: link:../server vitest: - specifier: ^3.0.0 - version: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1) + specifier: ^3.2.6 + version: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0) packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -393,10 +410,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -418,18 +431,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -518,317 +519,167 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1059,6 +910,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1085,6 +944,12 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@15.5.19': resolution: {integrity: sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==} @@ -1140,8 +1005,127 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@rollup/rollup-android-arm-eabi@4.61.1': resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} @@ -1318,6 +1302,9 @@ packages: cpu: [arm64] os: [win32] + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1434,17 +1421,24 @@ packages: resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true - '@vitest/coverage-v8@4.1.8': - resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + '@vitest/coverage-v8@3.2.6': + resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==} peerDependencies: - '@vitest/browser': 4.1.8 - vitest: 4.1.8 + '@vitest/browser': 3.2.6 + vitest: 3.2.6 peerDependenciesMeta: '@vitest/browser': optional: true @@ -1466,9 +1460,6 @@ packages: '@vitest/pretty-format@3.2.6': resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@3.2.6': resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} @@ -1481,9 +1472,6 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1540,6 +1528,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1548,6 +1540,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1564,8 +1560,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.3: - resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1606,6 +1602,9 @@ packages: brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -1622,7 +1621,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: 0.28.1 bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -1805,12 +1804,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.5.368: resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1841,13 +1849,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -2001,6 +2004,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2048,6 +2055,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2144,6 +2156,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2165,10 +2181,17 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -2239,6 +2262,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2277,8 +2374,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -2342,9 +2439,17 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -2419,10 +2524,6 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - obug@2.1.2: - resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} - engines: {node: '>=12.20.0'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2442,6 +2543,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2464,6 +2568,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -2518,7 +2626,7 @@ packages: engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: 8.5.15 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -2531,10 +2639,6 @@ packages: yaml: optional: true - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -2597,10 +2701,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -2625,6 +2725,11 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.61.1: resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2709,6 +2814,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -2733,12 +2842,25 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -2782,6 +2904,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2807,10 +2933,6 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -2872,7 +2994,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: ^8.4.12 + postcss: 8.5.15 typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -2957,31 +3079,74 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: 0.28.1 jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -3072,6 +3237,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3129,6 +3302,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -3219,8 +3397,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -3236,16 +3412,6 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -3321,165 +3487,98 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@emnapi/runtime@1.10.0': + '@emnapi/core@1.10.0': dependencies: + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.25.12': + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/linux-mips64el@0.27.7': + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': @@ -3647,6 +3746,17 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3688,6 +3798,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@next/env@15.5.19': {} '@next/swc-darwin-arm64@15.5.19': @@ -3714,7 +3831,70 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.19': optional: true - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@oxc-project/types@0.133.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.4 + rolldown: 1.0.3 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.1) + + '@rolldown/pluginutils@1.0.1': {} '@rollup/rollup-android-arm-eabi@4.61.1': optional: true @@ -3824,6 +4004,11 @@ snapshots: '@turbo/windows-arm64@2.9.16': optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -3992,31 +4177,31 @@ snapshots: '@typescript-eslint/types': 8.60.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.19.20))': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.3(@types/node@22.19.20) - transitivePeerDependencies: - - supports-color + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.1) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1)) - '@vitest/coverage-v8@4.1.8(vitest@3.2.6(@types/node@22.19.20)(jsdom@29.1.1))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0))': dependencies: + '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.8 - ast-v8-to-istanbul: 1.0.3 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.2 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1) + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color '@vitest/expect@3.2.6': dependencies: @@ -4026,22 +4211,18 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.20))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.20)(lightningcss@1.32.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@22.19.20) + vite: 7.3.5(@types/node@22.19.20)(lightningcss@1.32.0) '@vitest/pretty-format@3.2.6': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.8': - dependencies: - tinyrainbow: 3.1.0 - '@vitest/runner@3.2.6': dependencies: '@vitest/utils': 3.2.6 @@ -4064,12 +4245,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -4122,12 +4297,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} argparse@2.0.1: {} @@ -4140,7 +4319,7 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.3: + ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -4209,6 +4388,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -4226,9 +4409,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bundle-require@5.1.0(esbuild@0.27.7): + bundle-require@5.1.0(esbuild@0.28.1): dependencies: - esbuild: 0.27.7 + esbuild: 0.28.1 load-tsconfig: 0.2.5 bytes@3.1.2: {} @@ -4375,10 +4558,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.368: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -4399,63 +4588,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - esbuild@0.27.7: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -4684,6 +4844,11 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -4726,6 +4891,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} gopd@1.2.0: {} @@ -4809,6 +4983,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -4827,11 +5003,25 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jose@6.2.3: {} joycon@3.1.1: {} @@ -4922,6 +5112,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4950,7 +5189,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.3: + magicast@0.3.5: dependencies: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -4998,8 +5237,14 @@ snapshots: dependencies: brace-expansion: 1.1.15 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + minimist@1.2.8: {} + minipass@7.1.3: {} + mkdirp-classic@0.5.3: {} mlly@1.8.2: @@ -5034,7 +5279,7 @@ snapshots: '@next/env': 15.5.19 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001797 - postcss: 8.4.31 + postcss: 8.5.15 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) @@ -5064,8 +5309,6 @@ snapshots: object-inspect@1.13.4: {} - obug@2.1.2: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5091,6 +5334,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5109,6 +5354,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@0.1.13: {} path-to-regexp@8.4.2: {} @@ -5153,12 +5403,6 @@ snapshots: optionalDependencies: postcss: 8.5.15 - postcss@8.4.31: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -5236,8 +5480,6 @@ snapshots: react-is@17.0.2: {} - react-refresh@0.17.0: {} - react@19.2.7: {} readable-stream@3.6.2: @@ -5254,6 +5496,27 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + rollup@4.61.1: dependencies: '@types/estree': 1.0.9 @@ -5433,6 +5696,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -5451,12 +5716,30 @@ snapshots: std-env@3.10.0: {} - std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -5503,6 +5786,12 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -5524,8 +5813,6 @@ snapshots: tinyrainbow@2.0.0: {} - tinyrainbow@3.1.0: {} - tinyspy@4.0.4: {} tldts-core@6.1.86: {} @@ -5570,12 +5857,12 @@ snapshots: tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3): dependencies: - bundle-require: 5.1.0(esbuild@0.27.7) + bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.27.7 + esbuild: 0.28.1 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -5663,13 +5950,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@22.19.20): + vite-node@3.2.4(@types/node@22.19.20)(lightningcss@1.32.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.3(@types/node@22.19.20) + vite: 7.3.5(@types/node@22.19.20)(lightningcss@1.32.0) transitivePeerDependencies: - '@types/node' - jiti @@ -5684,9 +5971,9 @@ snapshots: - tsx - yaml - vite@6.4.3(@types/node@22.19.20): + vite@7.3.5(@types/node@22.19.20)(lightningcss@1.32.0): dependencies: - esbuild: 0.25.12 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 @@ -5695,12 +5982,25 @@ snapshots: optionalDependencies: '@types/node': 22.19.20 fsevents: 2.3.3 + lightningcss: 1.32.0 + + vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.20 + esbuild: 0.28.1 + fsevents: 2.3.3 - vitest@3.2.6(@types/node@22.19.20)(jsdom@29.1.1): + vitest@3.2.6(@types/node@22.19.20)(jsdom@29.1.1)(lightningcss@1.32.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.20)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.20)(lightningcss@1.32.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -5718,8 +6018,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.3(@types/node@22.19.20) - vite-node: 3.2.4(@types/node@22.19.20) + vite: 7.3.5(@types/node@22.19.20)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@22.19.20)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.20 @@ -5778,6 +6078,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} diff --git a/turbo.json b/turbo.json index 240f392..ebb77b2 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,8 @@ "test": { "dependsOn": ["^build"] }, - "test:unit": {} + "test:unit": { + "dependsOn": ["^build"] + } } }