@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install native QVAC runtime libraries
|
- name: Install native runtime and computer-use test dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: '22.17', cache: npm }
|
with: { node-version: '22.17', cache: npm }
|
||||||
- run: npm ci --ignore-scripts
|
- run: npm ci --ignore-scripts
|
||||||
|
|||||||
@@ -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
|
The harness is copied into vendor/agent-harness. It is not a symlink and the
|
||||||
dlinux platform is not vendored.
|
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
|
~~~bash
|
||||||
git clone <gitea-repository-url> jarvis-qvac
|
git clone <gitea-repository-url> jarvis-qvac
|
||||||
cd jarvis-qvac
|
cd jarvis-qvac
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ test('frame normalization reports native and scaled dimensions', async () => {
|
|||||||
const {FrameNormalizer}=await import('../computer-use/frame.js');
|
const {FrameNormalizer}=await import('../computer-use/frame.js');
|
||||||
const dir=await mkdtemp('/tmp/jarvis-frame-test-');
|
const dir=await mkdtemp('/tmp/jarvis-frame-test-');
|
||||||
try {
|
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']);
|
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);
|
assert.equal(made.status,0,made.error?.message || made.stderr);
|
||||||
const frame=await new FrameNormalizer().normalize(dir+'/input.png',dir+'/output.webp');
|
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);
|
assert.equal(frame.source_width,1600);assert.equal(frame.width,1280);assert.equal(frame.height,720);
|
||||||
} finally {await rm(dir,{recursive:true,force:true});}
|
} finally {await rm(dir,{recursive:true,force:true});}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ test('web_search can pin a scraped engine and fetch_page reads directly', async
|
|||||||
globalThis.fetch = async (url) => {
|
globalThis.fetch = async (url) => {
|
||||||
const href = String(url);
|
const href = String(url);
|
||||||
if (href.includes('duckduckgo.com')) return { status: 200, url: href, text: async () => '<a class="result__a" href="https://en.wikipedia.org/wiki/Example.com">Example.com</a>' };
|
if (href.includes('duckduckgo.com')) return { status: 200, url: href, text: async () => '<a class="result__a" href="https://en.wikipedia.org/wiki/Example.com">Example.com</a>' };
|
||||||
|
if (href.includes('bing.com') && href.includes('format=rss')) return { status: 200, url: href, text: async () => '<rss><item><title>Example.com</title><link>https://en.wikipedia.org/wiki/Example.com</link></item></rss>' };
|
||||||
if (href.includes('bing.com') || href.includes('google.com')) return { status: 200, url: href, text: async () => '' };
|
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 () => '<html><body><p>Readable article scraped directly</p></body></html>' };
|
if (href === 'https://example.com/article') return { status: 200, url: href, text: async () => '<html><body><p>Readable article scraped directly</p></body></html>' };
|
||||||
throw new Error('unexpected fetch ' + href);
|
throw new Error('unexpected fetch ' + href);
|
||||||
|
|||||||
@@ -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' });
|
globalThis.fetch = async url => response(url, 'binary', 200, { 'content-type': 'application/pdf' });
|
||||||
assert.match((await web.fetchPage('https://example.com')).error, /unsupported content type/);
|
assert.match((await web.fetchPage('https://example.com')).error, /unsupported content type/);
|
||||||
globalThis.fetch = async url => response(url, '<p>Verify you are human</p>');
|
globalThis.fetch = async url => response(url, '<p>Verify you are human</p>');
|
||||||
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));
|
globalThis.fetch = async url => response(url, 'x'.repeat(2 * 1024 * 1024 + 1));
|
||||||
assert.match((await web.fetchPage('https://example.com')).error, /limit/);
|
assert.match((await web.fetchPage('https://example.com')).error, /limit/);
|
||||||
} finally { globalThis.fetch = orig; }
|
} 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/);
|
await assert.rejects(web.readBodyWithTimeout(res, 30), /timed out/);
|
||||||
assert.equal(cancelled, true);
|
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, '<form id="challenge-form">Verify you are human</form>');
|
||||||
|
if (String(url).includes('bing')) return response(url, '<rss><item><title>Available</title><link>https://available.example/page</link></item></rss>');
|
||||||
|
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; }
|
||||||
|
});
|
||||||
|
|||||||
+35
-2
@@ -46,6 +46,24 @@ const ENGINE_ALIASES = {
|
|||||||
stackoverflow: 'stackoverflow',
|
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\/|<title>\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) {
|
function abortError(timeoutMs) {
|
||||||
const err = new Error('timed out after ' + timeoutMs + 'ms');
|
const err = new Error('timed out after ' + timeoutMs + 'ms');
|
||||||
err.name = 'AbortError';
|
err.name = 'AbortError';
|
||||||
@@ -102,7 +120,7 @@ function linkAbort(parent, child) {
|
|||||||
function fetchWithTimeout(url, opts, timeoutMs) {
|
function fetchWithTimeout(url, opts, timeoutMs) {
|
||||||
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
|
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : WEB_TIMEOUT_MS;
|
||||||
if (!(ms > 0)) return Promise.reject(abortError(0));
|
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;
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
let timer;
|
let timer;
|
||||||
const init = Object.assign({}, opts || {}, { headers });
|
const init = Object.assign({}, opts || {}, { headers });
|
||||||
@@ -284,6 +302,10 @@ async function fetchText(url, timeoutMs, opts) {
|
|||||||
let target = String(url), res;
|
let target = String(url), res;
|
||||||
for (let hop = 0; hop <= 5; hop++) {
|
for (let hop = 0; hop <= 5; hop++) {
|
||||||
net.assertPublicHttpUrl(target);
|
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));
|
res = await fetchWithTimeout(target, Object.assign({}, opts, { redirect: 'manual' }), remainingMs(deadline));
|
||||||
if (![301, 302, 303, 307, 308].includes(res.status)) break;
|
if (![301, 302, 303, 307, 308].includes(res.status)) break;
|
||||||
const location = res.headers && res.headers.get('location');
|
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') || '';
|
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);
|
if (type && !/text\/|json|xml|javascript/i.test(type)) throw new Error('unsupported content type: ' + type);
|
||||||
const text = await readBodyWithTimeout(res, remainingMs(deadline));
|
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) {
|
if (res.status >= 400) {
|
||||||
return { error: 'HTTP ' + res.status, url: String(res.url || target), status: res.status, text };
|
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,
|
body,
|
||||||
});
|
});
|
||||||
|
if (page.code) return page;
|
||||||
if (!page.error) {
|
if (!page.error) {
|
||||||
if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status };
|
if (isDdgChallenge(page.text)) return { error: 'duckduckgo bot challenge', url: page.url, status: page.status };
|
||||||
const posted = parseDdgHtmlHits(page.text, limit);
|
const posted = parseDdgHtmlHits(page.text, limit);
|
||||||
@@ -570,6 +602,7 @@ async function runWebSearch(query, opts) {
|
|||||||
const result = await fn(q, remainingMs(deadline), limit);
|
const result = await fn(q, remainingMs(deadline), limit);
|
||||||
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
|
if (searchHasHits(result)) return tagSearchHits(result, engine).slice(0, limit);
|
||||||
return {
|
return {
|
||||||
|
...(result && !Array.isArray(result) ? result : {}),
|
||||||
error: (result && result.error) || 'no search results',
|
error: (result && result.error) || 'no search results',
|
||||||
url: result && result.url,
|
url: result && result.url,
|
||||||
tried: [engine],
|
tried: [engine],
|
||||||
@@ -646,7 +679,7 @@ async function codeSearch(query, timeoutMs, limit) {
|
|||||||
|
|
||||||
async function webFetch(url, timeoutMs, opts) {
|
async function webFetch(url, timeoutMs, opts) {
|
||||||
const page = await fetchText(url, budgetMs(timeoutMs, PAGE_TIMEOUT_MS, SEARCH_BUDGET_MS));
|
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));
|
return Object.assign({ status: page.status, url: page.url, via: 'raw' }, reader.extractPage(page.text, page.url, opts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user