@demigodmode/pi-web-agent 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +19 -4
- package/dist/backends/config.d.ts +40 -0
- package/dist/backends/config.js +140 -1
- package/dist/backends/factory.d.ts +17 -1
- package/dist/backends/factory.js +170 -85
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +33 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +111 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +17 -1
- package/dist/commands/web-agent-config.js +131 -7
- package/dist/extension.d.ts +1 -0
- package/dist/extension.js +49 -3
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.js +64 -45
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +17 -2
- package/dist/fetch/headless-fetch.js +181 -9
- package/dist/fetch/http-fetch.js +16 -1
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/fetch/proxy-fetch.d.ts +22 -0
- package/dist/fetch/proxy-fetch.js +46 -0
- package/dist/jiti-compat-run.d.ts +1 -0
- package/dist/jiti-compat-run.js +9 -0
- package/dist/jiti-compat.d.ts +32 -0
- package/dist/jiti-compat.js +215 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +23 -0
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +21 -1
- package/dist/orchestration/research-orchestrator.js +40 -7
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.js +38 -3
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +10 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/readers/youtube-reader.d.ts +3 -1
- package/dist/readers/youtube-reader.js +11 -3
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +1 -2
- package/dist/search/youcom.js +15 -76
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +40 -0
- package/package.json +4 -3
- package/scripts/patch-jiti-compat.mjs +52 -8
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FailureInfo, SearchProviderName } from '../types.js';
|
|
2
|
+
export type ClassifiedProvider = SearchProviderName | 'firecrawl';
|
|
3
|
+
export type ResponseParts = {
|
|
4
|
+
status: number;
|
|
5
|
+
headers: Headers;
|
|
6
|
+
/** Parsed JSON body, or undefined when the body was empty or not JSON. */
|
|
7
|
+
json?: unknown;
|
|
8
|
+
};
|
|
9
|
+
export declare class BodyReadError extends Error {
|
|
10
|
+
readonly cause: unknown;
|
|
11
|
+
constructor(cause: unknown);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reads a response body once, keeping status and headers alongside the parsed JSON.
|
|
15
|
+
* A body that can't be read (connection dropped mid-stream) throws BodyReadError: that's
|
|
16
|
+
* a transport failure, not a malformed response.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readResponseParts(response: Response): Promise<ResponseParts & {
|
|
19
|
+
text: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function classifyHttpFailure(provider: ClassifiedProvider, parts: ResponseParts, now?: number): FailureInfo;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { parseRetryAfter } from './failure.js';
|
|
2
|
+
export class BodyReadError extends Error {
|
|
3
|
+
cause;
|
|
4
|
+
constructor(cause) {
|
|
5
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
6
|
+
this.cause = cause;
|
|
7
|
+
this.name = 'BodyReadError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reads a response body once, keeping status and headers alongside the parsed JSON.
|
|
12
|
+
* A body that can't be read (connection dropped mid-stream) throws BodyReadError: that's
|
|
13
|
+
* a transport failure, not a malformed response.
|
|
14
|
+
*/
|
|
15
|
+
export async function readResponseParts(response) {
|
|
16
|
+
let text;
|
|
17
|
+
try {
|
|
18
|
+
text = await response.text();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new BodyReadError(error);
|
|
22
|
+
}
|
|
23
|
+
let json;
|
|
24
|
+
try {
|
|
25
|
+
json = text ? JSON.parse(text) : undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
json = undefined;
|
|
29
|
+
}
|
|
30
|
+
return { status: response.status, headers: response.headers, json, text };
|
|
31
|
+
}
|
|
32
|
+
// Source: https://exa.ai/docs/reference/error-codes
|
|
33
|
+
const EXA_TAGS = {
|
|
34
|
+
RATE_LIMIT_EXCEEDED: 'rate_limited',
|
|
35
|
+
NO_MORE_CREDITS: 'quota_exhausted',
|
|
36
|
+
API_KEY_BUDGET_EXCEEDED: 'quota_exhausted',
|
|
37
|
+
TEAM_BUDGET_EXCEEDED: 'quota_exhausted',
|
|
38
|
+
INVALID_API_KEY: 'auth_failed',
|
|
39
|
+
FEATURE_DISABLED: 'auth_failed',
|
|
40
|
+
PROHIBITED_CONTENT: 'bad_request',
|
|
41
|
+
CONTENT_FILTER_ERROR: 'bad_request',
|
|
42
|
+
INVALID_REQUEST_BODY: 'bad_request',
|
|
43
|
+
INVALID_REQUEST: 'bad_request',
|
|
44
|
+
INVALID_NUM_RESULTS: 'bad_request'
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Conservative defaults for undocumented responses. A 401 disables the provider
|
|
48
|
+
* (low risk: resets on config change); an undocumented 403 is treated as a bot
|
|
49
|
+
* wall; an undocumented 402 is not assumed to mean quota.
|
|
50
|
+
*/
|
|
51
|
+
function defaultKind(status) {
|
|
52
|
+
if (status === 429)
|
|
53
|
+
return 'rate_limited';
|
|
54
|
+
if (status === 401)
|
|
55
|
+
return 'auth_failed';
|
|
56
|
+
if (status === 403)
|
|
57
|
+
return 'blocked';
|
|
58
|
+
if (status === 400 || status === 422)
|
|
59
|
+
return 'bad_request';
|
|
60
|
+
if (status === 408 || status >= 500)
|
|
61
|
+
return 'transient';
|
|
62
|
+
return 'bad_response';
|
|
63
|
+
}
|
|
64
|
+
function stringField(json, field) {
|
|
65
|
+
if (!json || typeof json !== 'object')
|
|
66
|
+
return undefined;
|
|
67
|
+
const value = json[field];
|
|
68
|
+
return typeof value === 'string' ? value : undefined;
|
|
69
|
+
}
|
|
70
|
+
export function classifyHttpFailure(provider, parts, now = Date.now()) {
|
|
71
|
+
const { status } = parts;
|
|
72
|
+
let kind;
|
|
73
|
+
let providerCode;
|
|
74
|
+
if (provider === 'exa') {
|
|
75
|
+
const tag = stringField(parts.json, 'tag');
|
|
76
|
+
if (tag && EXA_TAGS[tag]) {
|
|
77
|
+
kind = EXA_TAGS[tag];
|
|
78
|
+
providerCode = tag;
|
|
79
|
+
}
|
|
80
|
+
else if (status === 402) {
|
|
81
|
+
kind = 'quota_exhausted'; // documented 402 meaning for Exa
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (provider === 'firecrawl') {
|
|
85
|
+
// Source: https://docs.firecrawl.dev/api-reference/errors
|
|
86
|
+
if (status === 402)
|
|
87
|
+
kind = 'quota_exhausted';
|
|
88
|
+
}
|
|
89
|
+
else if (provider === 'youcom') {
|
|
90
|
+
// Source: https://you.com/docs/api-reference/search/v1-search (429 UNVERIFIED -> default)
|
|
91
|
+
// Documented for /v1/search; the client currently calls /v1/agents/search (#60).
|
|
92
|
+
if (status === 402)
|
|
93
|
+
kind = 'quota_exhausted';
|
|
94
|
+
else if (status === 403)
|
|
95
|
+
kind = 'auth_failed';
|
|
96
|
+
}
|
|
97
|
+
else if (provider === 'searxng') {
|
|
98
|
+
// Source: https://docs.searxng.org/dev/search_api.html (403 = format=json disabled in settings)
|
|
99
|
+
if (status === 403)
|
|
100
|
+
kind = 'auth_failed';
|
|
101
|
+
}
|
|
102
|
+
// brave, tavily, duckduckgo: defaults only (UNVERIFIED beyond 429; see research gate).
|
|
103
|
+
const resolved = kind ?? defaultKind(status);
|
|
104
|
+
const info = { kind: resolved, httpStatus: status, ...(providerCode ? { providerCode } : {}) };
|
|
105
|
+
if (resolved === 'rate_limited') {
|
|
106
|
+
const retryAfter = parseRetryAfter(parts.headers.get('retry-after'), now);
|
|
107
|
+
if (retryAfter !== undefined)
|
|
108
|
+
info.providerRetryAfterMs = retryAfter;
|
|
109
|
+
}
|
|
110
|
+
return info;
|
|
111
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FailureInfo } from '../types.js';
|
|
2
|
+
export declare const DEFAULT_COOLDOWN_MS = 60000;
|
|
3
|
+
export declare const MIN_COOLDOWN_MS = 1000;
|
|
4
|
+
export declare const MAX_COOLDOWN_MS: number;
|
|
5
|
+
export type ProviderHealthState = {
|
|
6
|
+
state: 'available';
|
|
7
|
+
} | {
|
|
8
|
+
state: 'cooling_down';
|
|
9
|
+
until: number;
|
|
10
|
+
failure: FailureInfo;
|
|
11
|
+
detail?: string;
|
|
12
|
+
} | {
|
|
13
|
+
state: 'disabled';
|
|
14
|
+
failure: FailureInfo;
|
|
15
|
+
detail?: string;
|
|
16
|
+
};
|
|
17
|
+
export type ProviderHealth = {
|
|
18
|
+
get(key: string): ProviderHealthState;
|
|
19
|
+
record(key: string, failure: FailureInfo, detail?: string): ProviderHealthState;
|
|
20
|
+
};
|
|
21
|
+
/** The applied cooldown. The provider's own value stays in failure.providerRetryAfterMs. */
|
|
22
|
+
export declare function cooldownFor(failure: FailureInfo): number;
|
|
23
|
+
/**
|
|
24
|
+
* Per backend set. The set is rebuilt on any effective config change, so fixed
|
|
25
|
+
* credentials or new endpoints take effect without restarting Pi.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createProviderHealth({ now }?: {
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}): ProviderHealth;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const DEFAULT_COOLDOWN_MS = 60_000;
|
|
2
|
+
export const MIN_COOLDOWN_MS = 1000;
|
|
3
|
+
export const MAX_COOLDOWN_MS = 15 * 60_000;
|
|
4
|
+
/** The applied cooldown. The provider's own value stays in failure.providerRetryAfterMs. */
|
|
5
|
+
export function cooldownFor(failure) {
|
|
6
|
+
const raw = failure.providerRetryAfterMs;
|
|
7
|
+
if (raw === undefined || Number.isNaN(raw))
|
|
8
|
+
return DEFAULT_COOLDOWN_MS;
|
|
9
|
+
return Math.min(MAX_COOLDOWN_MS, Math.max(MIN_COOLDOWN_MS, raw));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Per backend set. The set is rebuilt on any effective config change, so fixed
|
|
13
|
+
* credentials or new endpoints take effect without restarting Pi.
|
|
14
|
+
*/
|
|
15
|
+
export function createProviderHealth({ now = Date.now } = {}) {
|
|
16
|
+
const states = new Map();
|
|
17
|
+
function get(key) {
|
|
18
|
+
const current = states.get(key);
|
|
19
|
+
if (!current)
|
|
20
|
+
return { state: 'available' };
|
|
21
|
+
if (current.state === 'cooling_down' && current.until <= now()) {
|
|
22
|
+
states.delete(key);
|
|
23
|
+
return { state: 'available' };
|
|
24
|
+
}
|
|
25
|
+
return current;
|
|
26
|
+
}
|
|
27
|
+
function record(key, failure, detail) {
|
|
28
|
+
const current = get(key);
|
|
29
|
+
if (current.state === 'disabled')
|
|
30
|
+
return current;
|
|
31
|
+
switch (failure.kind) {
|
|
32
|
+
case 'rate_limited': {
|
|
33
|
+
const next = { state: 'cooling_down', until: now() + cooldownFor(failure), failure };
|
|
34
|
+
states.set(key, next);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
case 'quota_exhausted':
|
|
38
|
+
case 'auth_failed':
|
|
39
|
+
case 'not_configured': {
|
|
40
|
+
const next = { state: 'disabled', failure, ...(detail ? { detail } : {}) };
|
|
41
|
+
states.set(key, next);
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { get, record };
|
|
49
|
+
}
|
|
@@ -16,6 +16,9 @@ type CommandDeps = {
|
|
|
16
16
|
arch: string;
|
|
17
17
|
};
|
|
18
18
|
checkTypebox?: () => Promise<boolean>;
|
|
19
|
+
checkJitiCompat?: () => {
|
|
20
|
+
pending: string[];
|
|
21
|
+
};
|
|
19
22
|
checkBackends?: (config: BackendConfig) => Promise<string[]>;
|
|
20
23
|
getChangelog?: () => Promise<string | undefined>;
|
|
21
24
|
};
|
|
@@ -33,7 +36,20 @@ export declare function validateBackendUrl(value: string): {
|
|
|
33
36
|
ok: false;
|
|
34
37
|
message: string;
|
|
35
38
|
};
|
|
36
|
-
export declare function
|
|
39
|
+
export declare function validateAllowRanges(value: string): {
|
|
40
|
+
ok: true;
|
|
41
|
+
value: string;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
message: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function createBackendUrlEditor(theme: any, label: string, placeholderUrl: string, onOpenChange?: (open: boolean) => void, validate?: (value: string) => {
|
|
47
|
+
ok: true;
|
|
48
|
+
value: string;
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
message: string;
|
|
52
|
+
}): (currentValue: string, done: (selectedValue?: string) => void) => Component;
|
|
37
53
|
export declare function getInheritedConfigForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
|
|
38
54
|
export declare function getScopeDisplayConfig(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
|
|
39
55
|
export declare function getInheritedBackendsForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): BackendConfig;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
|
|
1
|
+
import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, mergeBackendConfigLayers, stripProxyCredentials, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
|
|
2
2
|
import { checkBackendHealth } from '../backends/doctor.js';
|
|
3
|
+
import { parseCidr } from '../fetch/network-guard.js';
|
|
3
4
|
import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
|
|
4
5
|
import { Container, Input, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
|
|
5
6
|
import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
|
|
6
7
|
import { loadPresentationConfigLayers, resetPresentationConfigScope, saveBackendConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
|
|
7
8
|
import { resolveBrowserExecutable } from '../fetch/browser-resolution.js';
|
|
9
|
+
import { createProxyFetch } from '../fetch/proxy-fetch.js';
|
|
10
|
+
import { checkJitiCompat } from '../jiti-compat.js';
|
|
8
11
|
import { getLatestChangelogEntry } from '../changelog-notice.js';
|
|
9
12
|
const PRESENTATION_TOOL_NAMES = ['web_explore'];
|
|
10
13
|
function parseScopeToken(token) {
|
|
@@ -29,7 +32,11 @@ function cloneBackendConfig(config) {
|
|
|
29
32
|
...config.fetch,
|
|
30
33
|
options: config.fetch.options ? { ...config.fetch.options } : undefined
|
|
31
34
|
},
|
|
32
|
-
headless: { ...config.headless }
|
|
35
|
+
headless: { ...config.headless },
|
|
36
|
+
proxy: config.proxy ? { ...config.proxy } : undefined,
|
|
37
|
+
network: config.network
|
|
38
|
+
? { ...config.network, ...(config.network.allowRanges ? { allowRanges: [...config.network.allowRanges] } : {}) }
|
|
39
|
+
: undefined
|
|
33
40
|
};
|
|
34
41
|
}
|
|
35
42
|
function sameJson(left, right) {
|
|
@@ -47,6 +54,37 @@ export function validateBackendUrl(value) {
|
|
|
47
54
|
return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
|
|
48
55
|
}
|
|
49
56
|
}
|
|
57
|
+
function splitRanges(value) {
|
|
58
|
+
return value
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((range) => range.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
export function validateAllowRanges(value) {
|
|
64
|
+
for (const range of splitRanges(value)) {
|
|
65
|
+
const cidr = parseCidr(range);
|
|
66
|
+
if (!cidr)
|
|
67
|
+
return { ok: false, message: `Not a valid CIDR range: ${range}` };
|
|
68
|
+
if (cidr.prefix === 0)
|
|
69
|
+
return { ok: false, message: `${range} allows every address. List specific ranges instead.` };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, value: splitRanges(value).join(', ') };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The extension re-applies this patch on load, so `pending` here means the
|
|
75
|
+
* patch could not be written (read-only install, permissions, or a dependency
|
|
76
|
+
* whose shape changed). Point at the manual script in that case: it is the
|
|
77
|
+
* same recovery step people have been finding by digging through issue #34.
|
|
78
|
+
*/
|
|
79
|
+
function formatJitiCompatLine(status) {
|
|
80
|
+
if (status.pending.length === 0)
|
|
81
|
+
return 'jsdom compat patch: ok';
|
|
82
|
+
return [
|
|
83
|
+
`jsdom compat patch: needed (${status.pending.join(', ')})`,
|
|
84
|
+
'Run: node ~/.pi/agent/npm/node_modules/@demigodmode/pi-web-agent/scripts/patch-jiti-compat.mjs',
|
|
85
|
+
'then restart Pi.'
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
50
88
|
async function defaultCheckTypebox() {
|
|
51
89
|
try {
|
|
52
90
|
await import('typebox');
|
|
@@ -87,8 +125,11 @@ function formatBackendSummary(config = DEFAULT_BACKEND_CONFIG) {
|
|
|
87
125
|
return [
|
|
88
126
|
searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
|
|
89
127
|
fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
|
|
90
|
-
`headless: ${config.headless.provider}
|
|
91
|
-
|
|
128
|
+
`headless: ${config.headless.provider}`,
|
|
129
|
+
config.proxy ? `proxy: ${stripProxyCredentials(config.proxy.url)}` : undefined
|
|
130
|
+
]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join('\n');
|
|
92
133
|
}
|
|
93
134
|
function formatConfigSummary(config) {
|
|
94
135
|
const lines = [`defaultMode: ${config.defaultMode}`];
|
|
@@ -119,7 +160,7 @@ function buildPresentationSettingsItems(scope, config) {
|
|
|
119
160
|
}))
|
|
120
161
|
];
|
|
121
162
|
}
|
|
122
|
-
export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChange) {
|
|
163
|
+
export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChange, validate = validateBackendUrl) {
|
|
123
164
|
return (currentValue, done) => {
|
|
124
165
|
onOpenChange?.(true);
|
|
125
166
|
const initialValue = currentValue && currentValue !== 'not set' ? currentValue : placeholderUrl;
|
|
@@ -143,7 +184,7 @@ export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChang
|
|
|
143
184
|
finish('');
|
|
144
185
|
return;
|
|
145
186
|
}
|
|
146
|
-
const validated =
|
|
187
|
+
const validated = validate(value);
|
|
147
188
|
if (!validated.ok) {
|
|
148
189
|
showError(validated.message);
|
|
149
190
|
return;
|
|
@@ -249,6 +290,26 @@ function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange
|
|
|
249
290
|
label: 'Firecrawl API key',
|
|
250
291
|
currentValue: 'env var',
|
|
251
292
|
values: ['env var']
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'backend:proxy:url',
|
|
296
|
+
label: 'Proxy URL',
|
|
297
|
+
currentValue: backends.proxy ? stripProxyCredentials(backends.proxy.url) : 'not set',
|
|
298
|
+
submenu: createBackendUrlEditor(theme, 'HTTP proxy URL', 'http://127.0.0.1:7890', onUrlEditorOpenChange)
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: 'backend:network:allowRanges',
|
|
302
|
+
label: 'Network allow list',
|
|
303
|
+
currentValue: backends.network?.allowRanges?.length ? backends.network.allowRanges.join(', ') : 'not set',
|
|
304
|
+
submenu: createBackendUrlEditor(theme, 'Private ranges to allow, comma separated CIDRs, e.g. 198.18.0.0/15',
|
|
305
|
+
// No prefilled example: pressing enter on an empty list must not quietly add an exception.
|
|
306
|
+
'', onUrlEditorOpenChange, validateAllowRanges)
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: 'backend:network:trustProxyDns',
|
|
310
|
+
label: 'Trust the upstream proxy to enforce private-address restrictions',
|
|
311
|
+
currentValue: backends.network?.trustProxyDns ? 'on' : 'off',
|
|
312
|
+
values: ['off', 'on']
|
|
252
313
|
}
|
|
253
314
|
];
|
|
254
315
|
}
|
|
@@ -421,6 +482,33 @@ export function applySettingsValue(state, id, newValue) {
|
|
|
421
482
|
delete currentBackends.fetch.baseUrl;
|
|
422
483
|
}
|
|
423
484
|
}
|
|
485
|
+
if (id === 'backend:proxy:url') {
|
|
486
|
+
if (newValue.trim()) {
|
|
487
|
+
currentBackends.proxy = { ...currentBackends.proxy, url: newValue.trim() };
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
delete currentBackends.proxy;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (id === 'backend:network:allowRanges') {
|
|
494
|
+
const ranges = splitRanges(newValue);
|
|
495
|
+
const next = { ...currentBackends.network };
|
|
496
|
+
if (ranges.length > 0) {
|
|
497
|
+
next.allowRanges = ranges;
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
delete next.allowRanges;
|
|
501
|
+
}
|
|
502
|
+
if (Object.keys(next).length > 0) {
|
|
503
|
+
currentBackends.network = next;
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
delete currentBackends.network;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (id === 'backend:network:trustProxyDns') {
|
|
510
|
+
currentBackends.network = { ...currentBackends.network, trustProxyDns: newValue === 'on' };
|
|
511
|
+
}
|
|
424
512
|
nextDrafts[nextScope] = currentDraft;
|
|
425
513
|
nextBackendDrafts[nextScope] = currentBackends;
|
|
426
514
|
return {
|
|
@@ -485,6 +573,34 @@ export function collapseBackendConfigToOverride(config, inheritedConfig) {
|
|
|
485
573
|
if (!sameJson(config.headless, inheritedConfig.headless)) {
|
|
486
574
|
override.headless = { ...config.headless };
|
|
487
575
|
}
|
|
576
|
+
if (!sameJson(config.proxy, inheritedConfig.proxy)) {
|
|
577
|
+
if (config.proxy) {
|
|
578
|
+
const { password: _password, ...proxy } = config.proxy;
|
|
579
|
+
override.proxy = { ...proxy };
|
|
580
|
+
}
|
|
581
|
+
else if (inheritedConfig.proxy) {
|
|
582
|
+
// The proxy was cleared at this scope; record an explicit disable so it
|
|
583
|
+
// overrides a proxy set in a lower (e.g. global) layer.
|
|
584
|
+
override.proxy = { url: '' };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!sameJson(config.network, inheritedConfig.network)) {
|
|
588
|
+
if (config.network) {
|
|
589
|
+
override.network = {
|
|
590
|
+
...config.network,
|
|
591
|
+
...(config.network.allowRanges ? { allowRanges: [...config.network.allowRanges] } : {})
|
|
592
|
+
};
|
|
593
|
+
// Cleared at this scope: an explicit empty list overrides the parent's.
|
|
594
|
+
if (!config.network.allowRanges && inheritedConfig.network?.allowRanges)
|
|
595
|
+
override.network.allowRanges = [];
|
|
596
|
+
}
|
|
597
|
+
else if (inheritedConfig.network) {
|
|
598
|
+
override.network = {
|
|
599
|
+
...(inheritedConfig.network.allowRanges ? { allowRanges: [] } : {}),
|
|
600
|
+
...(inheritedConfig.network.trustProxyDns ? { trustProxyDns: false } : {})
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
}
|
|
488
604
|
return override;
|
|
489
605
|
}
|
|
490
606
|
export function handleSettingsShortcut(data) {
|
|
@@ -669,7 +785,12 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
|
|
|
669
785
|
arch: process.arch
|
|
670
786
|
};
|
|
671
787
|
const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
|
|
672
|
-
const
|
|
788
|
+
const checkCompat = deps.checkJitiCompat ?? checkJitiCompat;
|
|
789
|
+
const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config, {
|
|
790
|
+
// An invalid proxy url is reported by validateBackendConfig below; never
|
|
791
|
+
// build a proxy agent from it (no connectivity check is attempted).
|
|
792
|
+
fetchImpl: config.proxy && isValidProxyUrl(config.proxy.url) ? createProxyFetch(config.proxy) : fetch
|
|
793
|
+
}));
|
|
673
794
|
const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
|
|
674
795
|
pi.registerCommand('web-agent', {
|
|
675
796
|
description: 'Open settings or manage pi-web-agent presentation config',
|
|
@@ -700,7 +821,10 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
|
|
|
700
821
|
'pi-web-agent: loaded',
|
|
701
822
|
`runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
|
|
702
823
|
`typebox: ${typeboxOk ? 'ok' : 'missing'}`,
|
|
824
|
+
formatJitiCompatLine(checkCompat()),
|
|
703
825
|
formatBackendSummary(backendConfig),
|
|
826
|
+
`network allow list: ${backendConfig.network?.allowRanges?.length ? backendConfig.network.allowRanges.join(', ') : 'none'}`,
|
|
827
|
+
`trust upstream proxy for private addresses: ${backendConfig.network?.trustProxyDns ? 'on' : 'off'}`,
|
|
704
828
|
backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
|
|
705
829
|
...backendHealth
|
|
706
830
|
];
|
package/dist/extension.d.ts
CHANGED
package/dist/extension.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Keep first: re-applies the jsdom/jiti compat patch before jsdom loads (#34).
|
|
2
|
+
import './jiti-compat-run.js';
|
|
1
3
|
import { DEFAULT_BACKEND_CONFIG } from './backends/config.js';
|
|
2
4
|
import { Type } from 'typebox';
|
|
3
5
|
import { registerWebAgentConfigCommands } from './commands/web-agent-config.js';
|
|
@@ -57,20 +59,64 @@ export default function extension(pi) {
|
|
|
57
59
|
registerWebAgentConfigCommands(pi);
|
|
58
60
|
const injectedWebExplore = pi.__webExploreTool;
|
|
59
61
|
let cachedBackendKey;
|
|
62
|
+
let cachedWorkflow;
|
|
60
63
|
let cachedWebExplore;
|
|
64
|
+
// Runs in flight per workflow, so a replaced workflow is never closed mid-run.
|
|
65
|
+
const activeRuns = new Map();
|
|
66
|
+
const retiring = new Set();
|
|
67
|
+
const closeWorkflow = (workflow) => {
|
|
68
|
+
retiring.delete(workflow);
|
|
69
|
+
void Promise.resolve(workflow.close?.()).catch(() => undefined);
|
|
70
|
+
};
|
|
71
|
+
const retire = (workflow) => {
|
|
72
|
+
if ((activeRuns.get(workflow) ?? 0) === 0) {
|
|
73
|
+
closeWorkflow(workflow);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
retiring.add(workflow);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const leased = (workflow) => ({
|
|
80
|
+
run: async (input) => {
|
|
81
|
+
activeRuns.set(workflow, (activeRuns.get(workflow) ?? 0) + 1);
|
|
82
|
+
try {
|
|
83
|
+
return await workflow.run(input);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
const remaining = (activeRuns.get(workflow) ?? 1) - 1;
|
|
87
|
+
if (remaining > 0) {
|
|
88
|
+
activeRuns.set(workflow, remaining);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
activeRuns.delete(workflow);
|
|
92
|
+
if (retiring.has(workflow))
|
|
93
|
+
closeWorkflow(workflow);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
61
98
|
async function getConfiguredWebExplore() {
|
|
62
99
|
if (injectedWebExplore)
|
|
63
100
|
return injectedWebExplore;
|
|
64
101
|
const backendConfig = await getEffectiveBackendConfig(pi);
|
|
65
102
|
const backendKey = JSON.stringify(backendConfig);
|
|
66
103
|
if (!cachedWebExplore || cachedBackendKey !== backendKey) {
|
|
104
|
+
if (cachedWorkflow)
|
|
105
|
+
retire(cachedWorkflow);
|
|
67
106
|
cachedBackendKey = backendKey;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
107
|
+
cachedWorkflow = createResearchWorkflow({ backendConfig });
|
|
108
|
+
cachedWebExplore = createWebExploreTool({ explore: leased(cachedWorkflow) });
|
|
71
109
|
}
|
|
72
110
|
return cachedWebExplore;
|
|
73
111
|
}
|
|
112
|
+
pi.on('session_shutdown', async () => {
|
|
113
|
+
const workflow = cachedWorkflow;
|
|
114
|
+
cachedWorkflow = undefined;
|
|
115
|
+
cachedWebExplore = undefined;
|
|
116
|
+
cachedBackendKey = undefined;
|
|
117
|
+
if (workflow)
|
|
118
|
+
retire(workflow);
|
|
119
|
+
});
|
|
74
120
|
pi.on('session_start', async (_event, ctx) => {
|
|
75
121
|
try {
|
|
76
122
|
const notice = await getUpdateChangelogNotice();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { BlockedAddressError, UnverifiedDestinationError, type NetworkGuard } from './network-guard.js';
|
|
2
|
+
export type Destination = {
|
|
3
|
+
action: 'connect';
|
|
4
|
+
host: string;
|
|
5
|
+
address: string;
|
|
6
|
+
} | {
|
|
7
|
+
action: 'delegate';
|
|
8
|
+
host: string;
|
|
9
|
+
} | {
|
|
10
|
+
action: 'refuse';
|
|
11
|
+
error: BlockedAddressError | UnverifiedDestinationError;
|
|
12
|
+
};
|
|
13
|
+
export type DestinationMode = {
|
|
14
|
+
/** An upstream proxy is configured. */
|
|
15
|
+
upstream: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The user explicitly trusts the upstream proxy to enforce private-address
|
|
18
|
+
* restrictions. Only meaningful with an upstream.
|
|
19
|
+
*/
|
|
20
|
+
trustProxyDns?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Turns one resolution into where a connection may go (spec revision 2).
|
|
24
|
+
*
|
|
25
|
+
* - blocked answers are refused in every mode
|
|
26
|
+
* - by default the connection goes to an address we checked, never a hostname
|
|
27
|
+
* something else will resolve
|
|
28
|
+
* - with a trusted upstream the hostname is delegated, which also covers names
|
|
29
|
+
* only that proxy can resolve
|
|
30
|
+
* - anything else unresolvable is refused
|
|
31
|
+
*/
|
|
32
|
+
export declare function decideDestination(host: string, guard: NetworkGuard, mode: DestinationMode): Promise<Destination>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { BlockedAddressError, UnverifiedDestinationError } from './network-guard.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns one resolution into where a connection may go (spec revision 2).
|
|
4
|
+
*
|
|
5
|
+
* - blocked answers are refused in every mode
|
|
6
|
+
* - by default the connection goes to an address we checked, never a hostname
|
|
7
|
+
* something else will resolve
|
|
8
|
+
* - with a trusted upstream the hostname is delegated, which also covers names
|
|
9
|
+
* only that proxy can resolve
|
|
10
|
+
* - anything else unresolvable is refused
|
|
11
|
+
*/
|
|
12
|
+
export async function decideDestination(host, guard, mode) {
|
|
13
|
+
const resolution = await guard.resolveHost(host);
|
|
14
|
+
if (resolution.status === 'blocked') {
|
|
15
|
+
return { action: 'refuse', error: new BlockedAddressError(resolution.host, resolution.address) };
|
|
16
|
+
}
|
|
17
|
+
if (mode.upstream && mode.trustProxyDns) {
|
|
18
|
+
return { action: 'delegate', host: resolution.host };
|
|
19
|
+
}
|
|
20
|
+
if (resolution.status === 'unresolved') {
|
|
21
|
+
return { action: 'refuse', error: new UnverifiedDestinationError(resolution.host) };
|
|
22
|
+
}
|
|
23
|
+
return { action: 'connect', host: resolution.host, address: resolution.addresses[0] };
|
|
24
|
+
}
|