Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 2026-09-18

### Changes

- [Navigator] A site that redirects to another host under the same domain — a bare domain sending the browser to
its `www.` host, for example — no longer fails every navigation. Explorbot used to compare the landing host to the
configured one exactly, so it rejected the page it had just loaded and reported `expected /, got /`. A host now
counts as the same site when it is the configured one or a subdomain of it; the port still has to match, the
scheme no longer does.
- [Navigator] When a navigation does land on a different site, the failure now names the full URL it reached
instead of just the path, so the mismatch is visible in the log.
- Network calls and API requests recorded during a run are matched against the site the same way, so calls made
from a redirected host are captured instead of silently dropped.

## 2026-09-17

### Changes
Expand Down
3 changes: 2 additions & 1 deletion src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Overlay, OverlayPage } from './utils/overlay.js';
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
import type { Region } from './utils/region.js';
import { safeFilename } from './utils/strings.ts';
import { isSameHostFamily } from './utils/url-matcher.js';
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';

const debugLog = createDebug('explorbot:action');
Expand Down Expand Up @@ -317,7 +318,7 @@ class Action {

const url = URL.parse(request.url());
if (!url) return;
if (url.origin !== this.baseOrigin) return;
if (!isSameHostFamily(url.href, this.baseOrigin)) return;

const call: NetworkCall = { method: request.method(), path: url.pathname, status };
if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status)) return;
Expand Down
32 changes: 9 additions & 23 deletions src/ai/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { createDebug, pluralize, tag } from '../utils/logger.js';
import { loop, pause } from '../utils/loop.js';
import { RulesLoader } from '../utils/rules-loader.ts';
import { normalizeInlineText } from '../utils/strings.ts';
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
import type { Agent, AgentDeps } from './agent.js';
import type { Conversation } from './conversation.js';
import type { Provider } from './provider.js';
Expand Down Expand Up @@ -99,15 +99,6 @@ class Navigator implements Agent {
return this.config.ai?.agents?.navigator?.verifyTimeout ?? 1500;
}

private getBaseOrigin(): string | null {
const baseUrl = this.config.playwright.url;
try {
return new URL(baseUrl).origin;
} catch {
return null;
}
}

private getComparableCurrentUrl(stateManager: any, expectedUrl: string): string {
const currentState = stateManager.getCurrentState();
if (!currentState) return '';
Expand All @@ -126,18 +117,12 @@ class Navigator implements Agent {
const currentFullUrl = currentState.fullUrl || currentState.url || '';
if (!currentFullUrl) return false;

try {
const currentOrigin = new URL(currentFullUrl).origin;
if (/^https?:\/\//i.test(expectedUrl)) {
return currentOrigin === new URL(expectedUrl).origin;
}
if (!/^https?:\/\//i.test(currentFullUrl)) return !/^https?:\/\//i.test(expectedUrl);
if (/^https?:\/\//i.test(expectedUrl)) return isSameHostFamily(currentFullUrl, expectedUrl);

const baseOrigin = this.getBaseOrigin();
if (!baseOrigin) return true;
return currentOrigin === baseOrigin;
} catch {
return !/^https?:\/\//i.test(expectedUrl);
}
const baseUrl = this.config.playwright.url;
if (!baseUrl) return true;
return isSameHostFamily(currentFullUrl, baseUrl);
}

private isOnExpectedPage(expectedUrl: string, stateManager: any): boolean {
Expand Down Expand Up @@ -325,8 +310,9 @@ class Navigator implements Agent {
lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
tag('warning').log(`Page state did not change at ${check.freshState.url}`);
} else {
lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
const reachedUrl = check.freshState.fullUrl || check.freshState.url;
lastFailure = `Reached ${reachedUrl}, expected ${expectedUrl}`;
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${reachedUrl}`);
}
batchFailures.push({
code: codeBlock,
Expand Down
3 changes: 2 additions & 1 deletion src/api/xhr-capture.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isSameHostFamily } from '../utils/url-matcher.js';
import { RequestResult, generateRequestId } from './request-result.ts';
import type { RequestStore } from './request-store.ts';

Expand Down Expand Up @@ -39,7 +40,7 @@ export class XhrCapture {

const method = request.method();
const url = request.url();
if (!url.startsWith(this.baseOrigin)) return;
if (!isSameHostFamily(url, this.baseOrigin)) return;

const status = response.status();

Expand Down
7 changes: 7 additions & 0 deletions src/utils/url-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,10 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
.filter(Boolean);
return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
}

export function isSameHostFamily(urlA: string, urlB: string): boolean {
const hostA = URL.parse(urlA)?.host.toLowerCase();
const hostB = URL.parse(urlB)?.host.toLowerCase();
if (!hostA || !hostB) return false;
return hostA === hostB || hostA.endsWith(`.${hostB}`) || hostB.endsWith(`.${hostA}`);
}
12 changes: 12 additions & 0 deletions tests/unit/navigator-origin-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ describe('Navigator origin guard', () => {
expect((navigator as any).isOnExpectedPage('/', stateManager)).toBe(false);
});

it('accepts the host the configured origin redirects to', () => {
const navigator = createNavigator('https://example.com');
const stateManager = {
getCurrentState: () => ({
url: '/',
fullUrl: 'https://www.example.com/',
}),
};

expect((navigator as any).isOnExpectedPage('/', stateManager)).toBe(true);
});

it('accepts the configured origin for relative expected URLs', () => {
const navigator = createNavigator();
const stateManager = {
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/navigator-resolve-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe('Navigator resolveState', () => {

await harness.navigator.resolveState('reach /defects', fakeActionResult('/login'), { expectedUrl: '/defects' });

expect(harness.sent.some((text) => text.includes('Reached /login, expected /defects'))).toBe(true);
expect(harness.sent.some((text) => text.includes(`Reached ${BASE_URL}/login, expected /defects`))).toBe(true);
});

it('resolves when a proposed step reaches the expected URL', async () => {
Expand Down Expand Up @@ -217,7 +217,7 @@ describe('Navigator resolveState', () => {
expect(resolved).toBe(false);
const retry = harness.sent[1];
expect(retry).toContain('<previous_failures>');
expect(retry).toContain('Reached /login, expected /defects');
expect(retry).toContain(`Reached ${BASE_URL}/login, expected /defects`);
expect(retry).toContain('Invalid email or password');
expect(retry).toContain('Full HTML context');
expect(retry).toContain('Choose exactly ONE path');
Expand Down
35 changes: 34 additions & 1 deletion tests/unit/url-matcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'bun:test';
import { ConfigParser } from '../../src/config';
import { normalizeUrl } from '../../src/state-manager';
import { extractStatePath, generalizeSegment, generalizeUrl, hasDynamicUrlSegment, isDynamicSegment, isSamePageFamily, matchesNavigationUrl, matchesUrl } from '../../src/utils/url-matcher';
import { extractStatePath, generalizeSegment, generalizeUrl, hasDynamicUrlSegment, isDynamicSegment, isSameHostFamily, isSamePageFamily, matchesNavigationUrl, matchesUrl } from '../../src/utils/url-matcher';

describe('url-matcher', () => {
beforeEach(() => {
Expand Down Expand Up @@ -282,4 +282,37 @@ describe('url-matcher', () => {
expect(isSamePageFamily('/plans/new', '/plans/a57eab1a')).toBe(false);
});
});
describe('isSameHostFamily', () => {
it('matches a host with its www redirect target', () => {
expect(isSameHostFamily('https://www.example.com/', 'https://example.com')).toBe(true);
expect(isSameHostFamily('https://example.com/', 'https://www.example.com')).toBe(true);
});

it('matches a subdomain with its parent domain', () => {
expect(isSameHostFamily('https://app.example.com/dashboard', 'https://example.com')).toBe(true);
});

it('matches across schemes', () => {
expect(isSameHostFamily('https://example.com/', 'http://example.com')).toBe(true);
});

it('rejects sibling subdomains', () => {
expect(isSameHostFamily('https://app.example.com/', 'https://auth.example.com')).toBe(false);
});

it('rejects a host that only shares a suffix without a dot boundary', () => {
expect(isSameHostFamily('https://example.com.evil.test/', 'https://example.com')).toBe(false);
expect(isSameHostFamily('https://notexample.com/', 'https://example.com')).toBe(false);
});

it('keeps ports significant', () => {
expect(isSameHostFamily('http://localhost:3000/', 'http://localhost:3001')).toBe(false);
expect(isSameHostFamily('http://localhost:3000/', 'http://localhost:3000')).toBe(true);
});

it('rejects urls without a host', () => {
expect(isSameHostFamily('/login', 'https://example.com')).toBe(false);
expect(isSameHostFamily('https://example.com/', '')).toBe(false);
});
});
});
Loading