diff --git a/.gitea/workflows/rolling-release.yml b/.gitea/workflows/rolling-release.yml index 6b244f6..fc7cfb8 100644 --- a/.gitea/workflows/rolling-release.yml +++ b/.gitea/workflows/rolling-release.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install native QVAC runtime libraries + - name: Install native runtime and computer-use test dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libvulkan1 mesa-vulkan-drivers + sudo apt-get install -y --no-install-recommends libvulkan1 mesa-vulkan-drivers python3-pil python3-gi gir1.2-glib-2.0 libei1 - uses: actions/setup-node@v4 with: node-version: 22 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2a48ad..1a16a7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install computer-use test dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends python3-pil python3-gi gir1.2-glib-2.0 libei1 - uses: actions/setup-node@v4 with: { node-version: '22.17', cache: npm } - run: npm ci --ignore-scripts diff --git a/README.md b/README.md index 2a91fea..51de85b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,10 @@ published bundle exists for the requested ref. Set `JARVIS_SERVER`, The harness is copied into vendor/agent-harness. It is not a symlink and the dlinux platform is not vendored. +The test suite also runs Python computer-use helpers. On Ubuntu, install their +dependencies with `sudo apt-get install python3-pil python3-gi gir1.2-glib-2.0 libei1` +before running `npm test` (use the distribution Python so it can find these packages). + ~~~bash git clone jarvis-qvac cd jarvis-qvac diff --git a/test/computer-functional.test.js b/test/computer-functional.test.js index b3922c0..788e6c9 100644 --- a/test/computer-functional.test.js +++ b/test/computer-functional.test.js @@ -91,8 +91,8 @@ test('frame normalization reports native and scaled dimensions', async () => { const {FrameNormalizer}=await import('../computer-use/frame.js'); const dir=await mkdtemp('/tmp/jarvis-frame-test-'); try { - const made=spawnSync('python3',['-c','from PIL import Image; import sys; Image.new("RGB",(1600,900),"red").save(sys.argv[1])',dir+'/input.png']); - assert.equal(made.status,0); + const made=spawnSync('python3',['-c','from PIL import Image; import sys; Image.new("RGB",(1600,900),"red").save(sys.argv[1])',dir+'/input.png'],{encoding:'utf8'}); + assert.equal(made.status,0,made.error?.message || made.stderr); const frame=await new FrameNormalizer().normalize(dir+'/input.png',dir+'/output.webp'); assert.equal(frame.source_width,1600);assert.equal(frame.width,1280);assert.equal(frame.height,720); } finally {await rm(dir,{recursive:true,force:true});} diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js index 09649ca..acfb6dc 100644 --- a/test/runtime-tools.test.js +++ b/test/runtime-tools.test.js @@ -145,6 +145,7 @@ test('web_search can pin a scraped engine and fetch_page reads directly', async globalThis.fetch = async (url) => { const href = String(url); if (href.includes('duckduckgo.com')) return { status: 200, url: href, text: async () => 'Example.com' }; + if (href.includes('bing.com') && href.includes('format=rss')) return { status: 200, url: href, text: async () => 'Example.comhttps://en.wikipedia.org/wiki/Example.com' }; if (href.includes('bing.com') || href.includes('google.com')) return { status: 200, url: href, text: async () => '' }; if (href === 'https://example.com/article') return { status: 200, url: href, text: async () => '

Readable article scraped directly

' }; throw new Error('unexpected fetch ' + href); diff --git a/test/web-scraping.test.js b/test/web-scraping.test.js index 6d290f3..2b6ba16 100644 --- a/test/web-scraping.test.js +++ b/test/web-scraping.test.js @@ -54,7 +54,7 @@ test('direct fetch checks redirects, rejects binary pages, and reports challenge globalThis.fetch = async url => response(url, 'binary', 200, { 'content-type': 'application/pdf' }); assert.match((await web.fetchPage('https://example.com')).error, /unsupported content type/); globalThis.fetch = async url => response(url, '

Verify you are human

'); - assert.match((await web.fetchPage('https://example.com')).warning, /challenge/); + assert.match((await web.fetchPage('https://challenge.example.com')).warning, /challenge/); globalThis.fetch = async url => response(url, 'x'.repeat(2 * 1024 * 1024 + 1)); assert.match((await web.fetchPage('https://example.com')).error, /limit/); } finally { globalThis.fetch = orig; } @@ -66,3 +66,36 @@ test('stalled streaming bodies are cancelled at the deadline', async () => { await assert.rejects(web.readBodyWithTimeout(res, 30), /timed out/); assert.equal(cancelled, true); }); + + +test('challenge responses fall back and cool down related search hosts', async () => { + const orig = globalThis.fetch; const calls = []; + globalThis.fetch = async url => { + calls.push(String(url)); + if (String(url).includes('duckduckgo')) return response(url, '
Verify you are human
'); + if (String(url).includes('bing')) return response(url, 'Availablehttps://available.example/page'); + return response(url, ''); + }; + try { + const hits = await web.runWebSearch('fallback', { limit: 1 }); + assert.equal(hits[0].source, 'bing_rss'); + const blocked = await web.runWebSearch('fallback', { engine: 'ddg_lite' }); + assert.equal(blocked.code, 'bot_challenge'); + assert.ok(blocked.retry_after_ms > 0); + assert.equal(calls.filter(url => url.includes('duckduckgo')).length, 1); + } finally { globalThis.fetch = orig; } +}); + +test('rate limits preserve retry guidance and never expose challenge content', async () => { + const orig = globalThis.fetch; let calls = 0; + globalThis.fetch = async url => { calls++; return response(url, 'blocked content', 429, { 'retry-after': '120' }); }; + try { + const result = await web.fetchPage('https://limited.example/page'); + assert.equal(result.code, 'rate_limited'); + assert.equal(result.retry_after_ms, 120000); + assert.equal(result.text, undefined); + assert.match(result.next_action, /browser/); + await web.fetchPage('https://limited.example/other'); + assert.equal(calls, 1); + } finally { globalThis.fetch = orig; } +}); diff --git a/vendor/agent-harness/agent/web-search.js b/vendor/agent-harness/agent/web-search.js index 2958077..3546c00 100644 --- a/vendor/agent-harness/agent/web-search.js +++ b/vendor/agent-harness/agent/web-search.js @@ -46,6 +46,24 @@ const ENGINE_ALIASES = { stackoverflow: 'stackoverflow', }; +// Share cooldowns across searches, including DuckDuckGo's HTML/lite hosts. +const blockedProviders = new Map(); +function providerKey(url) { + const host = new URL(url).hostname; + return /(^|\.)duckduckgo\.com$/.test(host) ? 'duckduckgo.com' : host; +} +function challengePage(text) { + const html = String(text || ''); + return /anomaly-modal|Unfortunately, bots use DuckDuckGo|id=["']challenge-form|\/cdn-cgi\/challenge-platform\/|\s*(?:Just a moment|Attention Required)/i.test(html) || + (reader.readableText(html).length < 2000 && /verify (?:that )?you are human|unusual traffic from your computer network|checking your browser|complete the security check/i.test(reader.readableText(html))); +} +function blockedResult(url, status, code, retryAfterMs) { + return { error: code === 'rate_limited' ? 'Provider rate limited requests' : 'Provider requires a browser security challenge', + code, url, status, retry_after_ms: retryAfterMs, + warning: 'Page is blocked by a rate limit or bot challenge; content is not verified.', + next_action: 'Try another search engine, or open this URL in your browser and complete any required verification.' }; +} + function abortError(timeoutMs) { const err = new Error('timed out after ' + timeoutMs + 'ms'); err.name = 'AbortError'; @@ -102,7 +120,7 @@ function linkAbort(parent, child) { function fetchWithTimeout(url, opts, timeoutMs) { const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS; if (!(ms > 0)) return Promise.reject(abortError(0)); - const headers = Object.assign({ 'user-agent': BROWSER_UA }, (opts && opts.headers) || {}); + const headers = Object.assign({ 'user-agent': BROWSER_UA, accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9' }, (opts && opts.headers) || {}); const controller = typeof AbortController === 'function' ? new AbortController() : null; let timer; const init = Object.assign({}, opts || {}, { headers }); @@ -284,6 +302,10 @@ async function fetchText(url, timeoutMs, opts) { let target = String(url), res; for (let hop = 0; hop <= 5; hop++) { net.assertPublicHttpUrl(target); + if (remainingMs(deadline) <= 0) throw abortError(ms); + const cooldown = blockedProviders.get(providerKey(target)); + if (cooldown && cooldown.until > Date.now()) return blockedResult(target, cooldown.status, cooldown.code, cooldown.until - Date.now()); + blockedProviders.delete(providerKey(target)); res = await fetchWithTimeout(target, Object.assign({}, opts, { redirect: 'manual' }), remainingMs(deadline)); if (![301, 302, 303, 307, 308].includes(res.status)) break; const location = res.headers && res.headers.get('location'); @@ -296,6 +318,15 @@ async function fetchText(url, timeoutMs, opts) { const type = res.headers && res.headers.get('content-type') || ''; if (type && !/text\/|json|xml|javascript/i.test(type)) throw new Error('unsupported content type: ' + type); const text = await readBodyWithTimeout(res, remainingMs(deadline)); + if (res.status === 429 || challengePage(text)) { + const code = res.status === 429 ? 'rate_limited' : 'bot_challenge'; + const retry = res.headers && res.headers.get('retry-after'); + const delay = retry ? (/^\d+$/.test(retry) ? Number(retry) * 1000 : Date.parse(retry) - Date.now()) : 60000; + const retryAfterMs = Math.min(3600000, Math.max(1000, Number.isFinite(delay) ? delay : 60000)); + if (blockedProviders.size >= 128) blockedProviders.delete(blockedProviders.keys().next().value); + blockedProviders.set(providerKey(target), { until: Date.now() + retryAfterMs, status: res.status, code }); + return blockedResult(target, res.status, code, retryAfterMs); + } if (res.status >= 400) { return { error: 'HTTP ' + res.status, url: String(res.url || target), status: res.status, text }; } @@ -440,6 +471,7 @@ async function duckDuckGoSearch(query, timeoutMs, limit) { }, body, }); + if (page.code) return page; if (!page.error) { if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status }; const posted = parseDdgHtmlHits(page.text, limit); @@ -570,6 +602,7 @@ async function runWebSearch(query, opts) { const result = await fn(q, remainingMs(deadline), limit); if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit); return { + ...(result && !Array.isArray(result) ? result : {}), error: (result && result.error) || 'no search results', url: result && result.url, tried: [engine], @@ -646,7 +679,7 @@ async function codeSearch(query, timeoutMs, limit) { async function webFetch(url, timeoutMs, opts) { const page = await fetchText(url, budgetMs(timeoutMs, PAGE_TIMEOUT_MS, SEARCH_BUDGET_MS)); - if (page.error) return { error: page.error, url: page.url, status: page.status, via: 'raw' }; + if (page.error) { const { text, ...failure } = page; return { ...failure, via: 'raw' }; } return Object.assign({ status: page.status, url: page.url, via: 'raw' }, reader.extractPage(page.text, page.url, opts)); }