Fix CI
Rolling release / release (push) Successful in 6m28s

This commit is contained in:
2026-09-13 19:53:14 -04:00
parent 599bfe440d
commit 1ca4224377
7 changed files with 82 additions and 7 deletions
+2 -2
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+2 -2
View File
@@ -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});}
+1
View File
@@ -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);
+34 -1
View File
@@ -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
View File
@@ -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));
} }