@openlines/opl-pi-sht 0.2.7 → 0.2.9
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/README.md +10 -7
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-modes.json.sample +3 -3
- package/configs/opl-webaccess.json.sample +5 -1
- package/extensions/opl-browser/README.md +20 -6
- package/extensions/opl-browser/browser.ts +76 -19
- package/extensions/opl-browser/config.ts +5 -0
- package/extensions/opl-browser/index.ts +13 -5
- package/extensions/opl-browser/validate.ts +123 -5
- package/extensions/opl-guardian/README.md +5 -5
- package/extensions/opl-guardian/guardian.ts +5 -1
- package/extensions/opl-guardian/index.ts +35 -59
- package/extensions/opl-modes/README.md +2 -2
- package/extensions/opl-questionnaire/README.md +1 -1
- package/extensions/opl-questionnaire/index.ts +36 -21
- package/extensions/opl-webaccess/README.md +8 -5
- package/extensions/opl-webaccess/config.ts +56 -5
- package/extensions/opl-webaccess/extract.ts +39 -14
- package/extensions/opl-webaccess/index.ts +27 -7
- package/extensions/opl-webaccess/providers/gemini.ts +6 -1
- package/extensions/opl-webaccess/providers/tavily.ts +6 -1
- package/extensions/opl-webaccess/storage.ts +8 -2
- package/extensions/opl-webaccess/utils.ts +117 -2
- package/package.json +4 -4
|
@@ -8,8 +8,6 @@ import {
|
|
|
8
8
|
type SessionBeforeSwitchEvent,
|
|
9
9
|
type ToolCallEvent,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Container, SelectList, Spacer, Text, type SelectItem } from "@earendil-works/pi-tui";
|
|
13
11
|
import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
|
|
14
12
|
import { join } from "node:path";
|
|
15
13
|
|
|
@@ -22,6 +20,12 @@ import {
|
|
|
22
20
|
import { getProtectedPathBlock, hasPendingUserWork } from "./policies.js";
|
|
23
21
|
|
|
24
22
|
const LOG_PATH = join("err", "guardian.jsonl");
|
|
23
|
+
const RED = "\u001b[31m";
|
|
24
|
+
const RESET_FOREGROUND = "\u001b[39m";
|
|
25
|
+
|
|
26
|
+
function red(text: string): string {
|
|
27
|
+
return `${RED}${text}${RESET_FOREGROUND}`;
|
|
28
|
+
}
|
|
25
29
|
|
|
26
30
|
export async function appendIncident(cwd: string, incident: GuardianIncident): Promise<void> {
|
|
27
31
|
const directory = join(cwd, "err");
|
|
@@ -50,9 +54,14 @@ function diagnostic(removed: number, logError?: unknown): string {
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
function appendDiagnostic(message: AssistantMessage, text: string, invalidOnly: boolean): AssistantMessage {
|
|
57
|
+
// For a mixed response (valid tool calls remain), prepend the notice rather than
|
|
58
|
+
// appending a trailing text block after toolCall blocks: providers such as Anthropic
|
|
59
|
+
// require each tool_use to be followed by a tool_result, so a trailing text block does
|
|
60
|
+
// not round-trip through the wire format. Text-before-tool-calls is the standard
|
|
61
|
+
// assistant shape. convertToLlm passes assistant messages through as-is.
|
|
53
62
|
const content = invalidOnly
|
|
54
63
|
? [{ type: "text" as const, text: `${text} No tool was executed; send another prompt to continue.` }]
|
|
55
|
-
: [
|
|
64
|
+
: [{ type: "text" as const, text }, ...message.content];
|
|
56
65
|
return { ...message, content, stopReason: invalidOnly ? "stop" : "toolUse" };
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -80,6 +89,14 @@ export async function guardMessageEnd(
|
|
|
80
89
|
|
|
81
90
|
export function createToolCallHandler(config: GuardianConfig) {
|
|
82
91
|
return async (event: ToolCallEvent, ctx: ExtensionContext) => {
|
|
92
|
+
// Defense in depth: createMessageEndHandler already drops malformed tool calls
|
|
93
|
+
// (blank id or name) before they reach execution, but block any that still get
|
|
94
|
+
// here anyway (e.g. a replay path or a provider that streamed them directly).
|
|
95
|
+
// Optional chaining tolerates a *missing* id/name, not just a blank string.
|
|
96
|
+
if (!event.toolCallId?.trim() || !event.toolName?.trim()) {
|
|
97
|
+
return { block: true, reason: "[opl-guardian] Blocked a malformed tool call with a blank id or name." };
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
let toolPath: string | undefined;
|
|
84
101
|
if (isToolCallEventType("read", event)) toolPath = event.input.path;
|
|
85
102
|
else if (isToolCallEventType("write", event)) toolPath = event.input.path;
|
|
@@ -92,7 +109,7 @@ export function createToolCallHandler(config: GuardianConfig) {
|
|
|
92
109
|
const reason = blocked.unresolved
|
|
93
110
|
? `[opl-guardian] Cannot safely resolve path "${blocked.path}"; ${blocked.operation} denied by protected-path policy.`
|
|
94
111
|
: `[opl-guardian] Path "${blocked.path}" is protected (${blocked.operation} denied).`;
|
|
95
|
-
if (ctx.hasUI) ctx.ui.notify(reason, "warning");
|
|
112
|
+
if (ctx.hasUI) ctx.ui.notify(red(reason), "warning");
|
|
96
113
|
return { block: true, reason };
|
|
97
114
|
}
|
|
98
115
|
}
|
|
@@ -106,10 +123,14 @@ export function createToolCallHandler(config: GuardianConfig) {
|
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
const command = event.input.command;
|
|
126
|
+
const preview = command.replace(/\s+/g, " ").trim();
|
|
127
|
+
// Fail closed: order "No" first so an accidental confirmation (or a replayed
|
|
128
|
+
// input event hitting a freshly-focused dialog) denies instead of approves.
|
|
129
|
+
// Only an explicit "Yes" selection approves the command.
|
|
130
|
+
const options = ["No", "Yes"];
|
|
109
131
|
if (ctx.mode === "rpc") {
|
|
110
|
-
const preview = command.replace(/\s+/g, " ").trim();
|
|
111
132
|
try {
|
|
112
|
-
const choice = await ctx.ui.select(`Dangerous command
|
|
133
|
+
const choice = await ctx.ui.select(red(`Dangerous command:\n\n${preview.slice(0, 256)}\n\nAllow?`), options);
|
|
113
134
|
if (choice === "Yes") return undefined;
|
|
114
135
|
} catch {
|
|
115
136
|
// A failed RPC dialog is not approval.
|
|
@@ -117,55 +138,10 @@ export function createToolCallHandler(config: GuardianConfig) {
|
|
|
117
138
|
return { block: true, reason: "[opl-guardian] Command blocked; confirmation was declined or unavailable." };
|
|
118
139
|
}
|
|
119
140
|
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
{ value: "no", label: "No" },
|
|
123
|
-
];
|
|
124
|
-
const choice = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) => {
|
|
125
|
-
const border = new DynamicBorder((text: string) => theme.fg("border", text));
|
|
126
|
-
const label = new Text(theme.fg("text", "Dangerous command detected:"), 1, 0);
|
|
127
|
-
const preview = command.replace(/\s+/g, " ").trim();
|
|
128
|
-
const commandText = new Text(theme.fg("error", preview.length > 256 ? `${preview.slice(0, 256)}…` : preview), 1, 0);
|
|
129
|
-
const question = new Text(theme.fg("text", "Allow this command?"), 1, 0);
|
|
130
|
-
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
131
|
-
selectedPrefix: (text) => theme.fg("accent", text),
|
|
132
|
-
selectedText: (text) => theme.fg("accent", text),
|
|
133
|
-
description: (text) => theme.fg("muted", text),
|
|
134
|
-
scrollInfo: (text) => theme.fg("dim", text),
|
|
135
|
-
noMatch: (text) => theme.fg("warning", text),
|
|
136
|
-
});
|
|
137
|
-
selectList.onSelect = (item) => done(item.value);
|
|
138
|
-
selectList.onCancel = () => done(null);
|
|
139
|
-
|
|
140
|
-
const container = new Container();
|
|
141
|
-
container.addChild(border);
|
|
142
|
-
container.addChild(new Spacer());
|
|
143
|
-
container.addChild(label);
|
|
144
|
-
container.addChild(commandText);
|
|
145
|
-
container.addChild(new Spacer());
|
|
146
|
-
container.addChild(question);
|
|
147
|
-
container.addChild(selectList);
|
|
148
|
-
container.addChild(new Spacer());
|
|
149
|
-
container.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0));
|
|
150
|
-
container.addChild(border);
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
render(width: number) { return container.render(width); },
|
|
154
|
-
invalidate() { container.invalidate(); },
|
|
155
|
-
handleInput(data: string) {
|
|
156
|
-
if (keybindings.matches(data, "app.tools.expand")) {
|
|
157
|
-
ctx.ui.setToolsExpanded(!ctx.ui.getToolsExpanded());
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
selectList.handleInput(data);
|
|
161
|
-
tui.requestRender();
|
|
162
|
-
},
|
|
163
|
-
};
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
if (choice === "yes") return undefined;
|
|
141
|
+
const choice = await ctx.ui.select(red(`Dangerous command:\n\n${preview.slice(0, 256)}\n\nAllow?`), options);
|
|
142
|
+
if (choice === "Yes") return undefined;
|
|
167
143
|
const reason = "[opl-guardian] Command blocked by user because it matches a dangerous pattern.";
|
|
168
|
-
ctx.ui.notify(reason, "warning");
|
|
144
|
+
ctx.ui.notify(red(reason), "warning");
|
|
169
145
|
return { block: true, reason };
|
|
170
146
|
};
|
|
171
147
|
}
|
|
@@ -181,7 +157,7 @@ export function createMessageEndHandler(config: GuardianConfig) {
|
|
|
181
157
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
182
158
|
});
|
|
183
159
|
if (!guarded) return undefined;
|
|
184
|
-
ctx.ui?.notify(guarded.diagnostic, "warning");
|
|
160
|
+
ctx.ui?.notify(red(guarded.diagnostic), "warning");
|
|
185
161
|
return { message: guarded.message };
|
|
186
162
|
};
|
|
187
163
|
}
|
|
@@ -202,7 +178,7 @@ export function createSessionBeforeSwitchHandler(config: GuardianConfig) {
|
|
|
202
178
|
}
|
|
203
179
|
|
|
204
180
|
if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
|
|
205
|
-
return await ctx.ui.confirm(title, message) ? undefined : { cancel: true };
|
|
181
|
+
return await ctx.ui.confirm(red(title), red(message)) ? undefined : { cancel: true };
|
|
206
182
|
};
|
|
207
183
|
}
|
|
208
184
|
|
|
@@ -211,8 +187,8 @@ export function createSessionBeforeForkHandler(config: GuardianConfig) {
|
|
|
211
187
|
if (!config.confirmDestructive.forkSession) return undefined;
|
|
212
188
|
if (!ctx.hasUI) return config.confirmDestructive.blockWithoutUI ? { cancel: true } : undefined;
|
|
213
189
|
const confirmed = await ctx.ui.confirm(
|
|
214
|
-
`Fork from entry ${event.entryId.slice(0, 8)}
|
|
215
|
-
"Create a new session branch from this point?",
|
|
190
|
+
red(`Fork from entry ${event.entryId.slice(0, 8)}?`),
|
|
191
|
+
red("Create a new session branch from this point?"),
|
|
216
192
|
);
|
|
217
193
|
return confirmed ? undefined : { cancel: true };
|
|
218
194
|
};
|
|
@@ -222,7 +198,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
222
198
|
const { config, warnings } = loadGuardianConfig();
|
|
223
199
|
pi.on("session_start", async (_event, ctx) => {
|
|
224
200
|
if (warnings.length && ctx.hasUI) {
|
|
225
|
-
ctx.ui.notify(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}
|
|
201
|
+
ctx.ui.notify(red(`[opl-guardian] Configuration warning(s):\n${warnings.join("\n")}`), "warning");
|
|
226
202
|
}
|
|
227
203
|
});
|
|
228
204
|
|
|
@@ -9,7 +9,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
|
|
|
9
9
|
- `/plan` toggles plan mode, creates or loads a named plan, or accepts `/plan off`.
|
|
10
10
|
- `/execute` selects or executes an existing plan; `/execute <name>` runs a named plan.
|
|
11
11
|
- `--chat` and `--plan` start Pi in the corresponding read-only mode.
|
|
12
|
-
- The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `
|
|
12
|
+
- The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `ctrl+alt+m` does not collide with any built-in Pi keybinding; set `shortcuts.cycleMode` to rebind.
|
|
13
13
|
|
|
14
14
|
Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A
|
|
|
17
17
|
|
|
18
18
|
### Mode behavior
|
|
19
19
|
|
|
20
|
-
Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy.
|
|
20
|
+
Chat and plan modes replace the active tools with configurable read-only tool lists and restrict Bash to safe inspection patterns. The allowlist is checked **per shell segment**, not just at the start of the command line: `&&`, `||`, a lone `&` (backgrounding), `|`, `;`, newlines, `$(...)`, backticks, and `<(`/`>(` each begin a segment, and every segment must match a safe pattern. Segment scanning is quote-aware, so `grep "a|b"` is one command, but a `$(...)` or backtick payload still runs inside double quotes and is split out and checked, so `echo "$(node -e ...)"` cannot hide behind the quoted argument. `cat f && node -e '...'`, `ls; python -c '...'`, and `cat x & rm -rf /` are blocked because their second segment is not safe-listed. Destructive patterns are still checked even when a command matches a safe pattern. The allowlist covers read-only inspection and text filters only: `awk`, `sed`, `xargs`, `perl`, `python`, and `env` are deliberately absent because they can write files, exec programs from their arguments, or dump API keys. Valid user-provided safe and destructive pattern arrays replace the built-in lists, rather than extending them. An explicit empty per-mode array replaces that policy with none; a malformed non-empty per-mode array is ignored and inherits the existing policy. The top-level `bashPatterns.safePatterns`/`destructivePatterns` behave differently: an empty top-level array falls back to the built-in list (it cannot opt out), so opt a mode out with a per-mode `safePatterns: []` or `destructivePatterns: []` instead.
|
|
21
21
|
|
|
22
22
|
The shared destructive base blocks file mutation primitives (`rm`, `rmdir`, `mv`, `cp`, `mkdir`, `touch`, `chmod`, `chown`, `tee`, `dd`, `shred`, `truncate`), `find -delete`/`-exec`/`-execdir`/`--exec-batch` and find's file-writing predicates (`-fprint`, `-fprint0`, `-fprintf`, `-fls`), any `--output` file (`sort --output`, `git log/diff/show --output`), `sort -o`/`tree -o` in any argument position, `fd -x`/`-X` (and clusters like `-Hx`), `rg --pre`, `jq`'s environment access (`jq -n env`, `$ENV`), `npm`/`yarn`/`pip` install and update forms including `npm audit fix`, state-changing `git` subcommands (including `branch -d`/`-D`/`-m`/`-M`/`-c`/`-C`/`--delete`/`--unset-upstream` and the writing `git remote` subcommands), `sudo`/`su`, `kill`/`pkill`, shell spawns, editors, `uniq`'s output operand (`uniq -c in.txt out.txt`; flags are not operands, so `uniq -c sorted.txt` stays a read), and every redirect that names a file - while descriptor duplication (`2>&1`, `>&2`, `>&-`) and a redirect whose target is exactly `/dev/null` are not writes and stay allowed (`> /dev/null/../tmp/x` is a write, so a path that merely starts with that prefix is blocked). `env` and `printenv` are not safe-listed, since they could dump provider API keys into the model context. Program-name entries are anchored to command position and matched per segment, so a read-only command that merely *mentions* a dangerous word in an argument (`du -sh`, `find . -name '*.sh'`, `ls cp/`, `cat mv.sh`, `git log --grep=rm`, `grep -rn 'touch' src`) stays allowed, and descriptor duplication (`2>&1`, `>&2`, `>&-`) is not mistaken for a file write. Every destructive pattern is tested against the command and each of its segments, with and without quotes/backslashes stripped, so `r"m"` and `-del"ete"` cannot dodge the list. That blocklist split deliberately ignores quotes, so an operator inside a quoted string (`echo "a > b"`) is over-blocked rather than trusted. This is still a heuristic backstop, not a process-isolation boundary: `npx` and any command whose *name* comes from an expansion are outside what a pattern list can catch.
|
|
23
23
|
|
|
@@ -12,7 +12,7 @@ Adds a model-callable interactive questionnaire for discrete choices, preference
|
|
|
12
12
|
|
|
13
13
|
- Renders one question as a compact option list and multiple questions as tabs with a final Submit tab.
|
|
14
14
|
- Questions require an `id`, prompt, options, and optional label or free-text `allowOther` response.
|
|
15
|
-
- Rejects duplicate IDs and questions that cannot be answered; returns `(no response)` for an empty custom answer.
|
|
15
|
+
- Rejects duplicate IDs, blank IDs, and questions that cannot be answered; returns `(no response)` for an empty custom answer.
|
|
16
16
|
- Returns structured answers containing question ID, selected value and label, custom-text flag, and option index where applicable.
|
|
17
17
|
- Blocks submission until every multi-question prompt has an answer.
|
|
18
18
|
- Returns a clear error in headless sessions so the model can use plain-text questions instead.
|
|
@@ -27,16 +27,41 @@ import {
|
|
|
27
27
|
type RenderOption,
|
|
28
28
|
} from "./types.js";
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** Build an error tool result. Marked as an error (not a cancellation) so the
|
|
31
|
+
* result card renders the message instead of mislabeling it "Cancelled". */
|
|
32
|
+
export function errorResult(
|
|
31
33
|
message: string,
|
|
32
|
-
|
|
33
|
-
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
|
|
34
|
+
): { content: { type: "text"; text: string }[]; isError: true; details: QuestionnaireResult } {
|
|
34
35
|
return {
|
|
35
36
|
content: [{ type: "text", text: message }],
|
|
36
|
-
|
|
37
|
+
isError: true,
|
|
38
|
+
details: { questions: [], answers: [], cancelled: false },
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Validate the question list. Returns the first error message, or null when valid.
|
|
43
|
+
* Blank ids are rejected (answers are keyed by id and "" would produce an empty key),
|
|
44
|
+
* as are duplicate ids and questions with nothing selectable. */
|
|
45
|
+
export function validateQuestions(questions: Question[]): string | null {
|
|
46
|
+
const seenIds = new Set<string>();
|
|
47
|
+
for (const q of questions) {
|
|
48
|
+
if (q.id.trim() === "") {
|
|
49
|
+
return "Error: Question id must be a non-empty string";
|
|
50
|
+
}
|
|
51
|
+
if (seenIds.has(q.id)) {
|
|
52
|
+
return `Error: Duplicate question id "${q.id}" — question ids must be unique`;
|
|
53
|
+
}
|
|
54
|
+
seenIds.add(q.id);
|
|
55
|
+
}
|
|
56
|
+
for (const q of questions) {
|
|
57
|
+
const allowOther = q.allowOther !== false;
|
|
58
|
+
if (q.options.length === 0 && !allowOther) {
|
|
59
|
+
return `Error: Question "${q.id}" has no options and allowOther is false — nothing is selectable`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
export default function questionnaire(pi: ExtensionAPI) {
|
|
41
66
|
pi.registerTool({
|
|
42
67
|
name: "questionnaire",
|
|
@@ -58,23 +83,9 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
58
83
|
return errorResult("Error: No questions provided");
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (seenIds.has(q.id)) {
|
|
65
|
-
return errorResult(`Error: Duplicate question id "${q.id}" — question ids must be unique`);
|
|
66
|
-
}
|
|
67
|
-
seenIds.add(q.id);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Reject unselectable questions (no options and no free-text fallback).
|
|
71
|
-
for (const q of params.questions) {
|
|
72
|
-
const allowOther = q.allowOther !== false;
|
|
73
|
-
if (q.options.length === 0 && !allowOther) {
|
|
74
|
-
return errorResult(
|
|
75
|
-
`Error: Question "${q.id}" has no options and allowOther is false — nothing is selectable`,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
86
|
+
const validationError = validateQuestions(params.questions);
|
|
87
|
+
if (validationError) {
|
|
88
|
+
return errorResult(validationError);
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
// Normalize questions with defaults
|
|
@@ -424,6 +435,10 @@ export default function questionnaire(pi: ExtensionAPI) {
|
|
|
424
435
|
},
|
|
425
436
|
|
|
426
437
|
renderResult(result, _options, theme, _context) {
|
|
438
|
+
if (result.isError) {
|
|
439
|
+
const text = result.content[0];
|
|
440
|
+
return new Text(theme.fg("error", text?.type === "text" ? text.text : ""), 0, 0);
|
|
441
|
+
}
|
|
427
442
|
const details = result.details as QuestionnaireResult | undefined;
|
|
428
443
|
if (!details) {
|
|
429
444
|
const text = result.content[0];
|
|
@@ -5,8 +5,8 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
|
|
|
5
5
|
## Commands, flags, and shortcuts
|
|
6
6
|
|
|
7
7
|
- Tools: `web_search`, `fetch_content`, and `get_search_content`.
|
|
8
|
-
- `web_search` accepts `query` or parallel `queries
|
|
9
|
-
- `fetch_content` accepts `url` or `urls` with at most three requests in flight.
|
|
8
|
+
- `web_search` accepts `query` or parallel `queries` (at most 10 per call).
|
|
9
|
+
- `fetch_content` accepts `url` or `urls` (at most 20 per call) with at most three requests in flight.
|
|
10
10
|
- `get_search_content` uses a prior `responseId`, with `queryIndex`, `urlIndex`, or exact `url` selection. It returns one bounded page; pass `offset` to continue a truncated retrieval.
|
|
11
11
|
- No slash commands or shortcuts.
|
|
12
12
|
|
|
@@ -14,8 +14,8 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
|
|
|
14
14
|
|
|
15
15
|
- Searches through `gemini`, `tavily`, `ddgs`, `searxng`, or `exa`; multiple queries run concurrently and results include citations where available.
|
|
16
16
|
- Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, passes PDF responses to the PDF extractor, and returns plain text, Markdown, and JSON directly.
|
|
17
|
-
- Restricts `fetch_content` to http/https,
|
|
18
|
-
- Caps initial tool output at a configurable length (default 30,000 characters), then keeps it for retrieval for one hour or until the session ends.
|
|
17
|
+
- Restricts `fetch_content` to http/https and blocks private-range, link-local, and reserved hosts by default, re-checks every redirect hop, and always blocks cloud-metadata endpoints (SSRF guard). Loopback (`localhost`/`127.0.0.0/8`/`::1`) is allowed by default for local development; set `allowPrivateNetwork: true` to reach private ranges. Caps each response at 10 MB and applies a 30s timeout; Gemini keys are sent via the `x-goog-api-key` header rather than the query string.
|
|
18
|
+
- Caps initial tool output at a configurable length (default 30,000 characters), then keeps it for retrieval for one hour (expiry is enforced on read, not just on write) or until the session ends.
|
|
19
19
|
- Pages `get_search_content` results (default 30,000 characters per page) with `offset` continuation, so a model never loads the whole stored body in one call.
|
|
20
20
|
- Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ Copy [`configs/opl-webaccess.json.sample`](../../configs/opl-webaccess.json.samp
|
|
|
33
33
|
}
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Provider fields include `apiKeyEnv`, `apiUrl`, `baseUrl`, `model`, `maxResults`, `instanceUrl`, `categories`, `safeSearch`, `searchType`, and `includeSummary`. API keys are read only from named environment variables.
|
|
36
|
+
Provider fields include `apiKeyEnv`, `apiUrl`, `baseUrl`, `model`, `maxResults`, `instanceUrl`, `categories`, `safeSearch`, `searchType`, and `includeSummary`. API keys are read only from named environment variables. Malformed config falls back to Gemini defaults - a non-object config, a non-string `provider`, a non-object `providers` map, or non-positive cap values (`maxContentChars`/`maxRetrievalChars`) are all treated as unset; an unknown provider returns an error.
|
|
37
37
|
|
|
38
38
|
Optional top-level caps control how much content reaches the model:
|
|
39
39
|
|
|
@@ -41,6 +41,9 @@ Optional top-level caps control how much content reaches the model:
|
|
|
41
41
|
|---|---|---|
|
|
42
42
|
| `maxContentChars` | `30000` | Cap on the initial `web_search`/`fetch_content` body. |
|
|
43
43
|
| `maxRetrievalChars` | `30000` | Cap on one `get_search_content` page. Pass `offset` to continue. |
|
|
44
|
+
| `maxSearchQueries` | `10` | Cap on how many queries one `web_search` call runs. Excess queries are skipped and reported. |
|
|
45
|
+
| `maxFetchUrls` | `20` | Cap on how many URLs one `fetch_content` call fetches. Excess URLs are skipped and reported. |
|
|
46
|
+
| `allowPrivateNetwork` | `false` | Allow `fetch_content` to reach private/link-local ranges (loopback is always allowed; cloud metadata is always blocked). Provider API endpoints (e.g. `ddgs.apiUrl`, `searxng.instanceUrl`) are excluded from this guard. |
|
|
44
47
|
|
|
45
48
|
Install extraction dependencies before use:
|
|
46
49
|
|
|
@@ -22,16 +22,36 @@ export interface WebAccessConfig {
|
|
|
22
22
|
maxContentChars?: number;
|
|
23
23
|
/** Cap on one get_search_content retrieval page (chars). */
|
|
24
24
|
maxRetrievalChars?: number;
|
|
25
|
+
/** Cap on the number of queries a single web_search call may run. */
|
|
26
|
+
maxSearchQueries?: number;
|
|
27
|
+
/** Cap on the number of URLs a single fetch_content call may fetch. */
|
|
28
|
+
maxFetchUrls?: number;
|
|
29
|
+
/** Allow fetch_content to reach private/link-local ranges (loopback is always
|
|
30
|
+
* allowed; cloud metadata is always blocked). Default false. */
|
|
31
|
+
allowPrivateNetwork?: boolean;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export const DEFAULT_MAX_CONTENT_CHARS = 30_000;
|
|
28
35
|
export const DEFAULT_MAX_RETRIEVAL_CHARS = 30_000;
|
|
36
|
+
export const DEFAULT_MAX_SEARCH_QUERIES = 10;
|
|
37
|
+
export const DEFAULT_MAX_FETCH_URLS = 20;
|
|
29
38
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
39
|
+
/** Coerce an unknown value into a positive integer, else the fallback. */
|
|
40
|
+
function positiveInt(value: unknown, fallback: number): number {
|
|
41
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve configurable caps, falling back to defaults when a cap is missing or not
|
|
45
|
+
* a positive finite number (a string/negative/zero cap would otherwise make
|
|
46
|
+
* truncate/slice produce empty, inverted, or unbounded output). */
|
|
47
|
+
export function resolveCaps(
|
|
48
|
+
cfg: Pick<WebAccessConfig, "maxContentChars" | "maxRetrievalChars" | "maxSearchQueries" | "maxFetchUrls">,
|
|
49
|
+
) {
|
|
32
50
|
return {
|
|
33
|
-
maxContentChars: cfg.maxContentChars
|
|
34
|
-
maxRetrievalChars: cfg.maxRetrievalChars
|
|
51
|
+
maxContentChars: positiveInt(cfg.maxContentChars, DEFAULT_MAX_CONTENT_CHARS),
|
|
52
|
+
maxRetrievalChars: positiveInt(cfg.maxRetrievalChars, DEFAULT_MAX_RETRIEVAL_CHARS),
|
|
53
|
+
maxSearchQueries: positiveInt(cfg.maxSearchQueries, DEFAULT_MAX_SEARCH_QUERIES),
|
|
54
|
+
maxFetchUrls: positiveInt(cfg.maxFetchUrls, DEFAULT_MAX_FETCH_URLS),
|
|
35
55
|
};
|
|
36
56
|
}
|
|
37
57
|
|
|
@@ -50,12 +70,43 @@ const DEFAULT_CONFIG: WebAccessConfig = {
|
|
|
50
70
|
|
|
51
71
|
export function loadConfig(): WebAccessConfig {
|
|
52
72
|
if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
|
|
73
|
+
let parsed: unknown;
|
|
53
74
|
try {
|
|
54
|
-
|
|
75
|
+
parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
55
76
|
} catch {
|
|
56
77
|
console.error(`[opl-webaccess] failed to parse ${CONFIG_PATH}, using defaults`);
|
|
57
78
|
return DEFAULT_CONFIG;
|
|
58
79
|
}
|
|
80
|
+
return normalizeConfig(parsed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Validate a parsed config so malformed fields fall back to defaults instead of
|
|
84
|
+
* silently misbehaving downstream (a non-object config, a non-string provider, or
|
|
85
|
+
* a non-object providers map are all treated as unset). Cap values are validated
|
|
86
|
+
* in resolveCaps. */
|
|
87
|
+
function normalizeConfig(parsed: unknown): WebAccessConfig {
|
|
88
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
89
|
+
console.error("[opl-webaccess] config is not a JSON object, using defaults");
|
|
90
|
+
return DEFAULT_CONFIG;
|
|
91
|
+
}
|
|
92
|
+
const record = parsed as Record<string, unknown>;
|
|
93
|
+
const provider =
|
|
94
|
+
typeof record.provider === "string" && record.provider.trim() !== ""
|
|
95
|
+
? record.provider
|
|
96
|
+
: DEFAULT_CONFIG.provider;
|
|
97
|
+
const providers =
|
|
98
|
+
typeof record.providers === "object" && record.providers !== null && !Array.isArray(record.providers)
|
|
99
|
+
? (record.providers as Record<string, ProviderConfig>)
|
|
100
|
+
: DEFAULT_CONFIG.providers;
|
|
101
|
+
return {
|
|
102
|
+
provider,
|
|
103
|
+
providers,
|
|
104
|
+
maxContentChars: record.maxContentChars as number | undefined,
|
|
105
|
+
maxRetrievalChars: record.maxRetrievalChars as number | undefined,
|
|
106
|
+
maxSearchQueries: record.maxSearchQueries as number | undefined,
|
|
107
|
+
maxFetchUrls: record.maxFetchUrls as number | undefined,
|
|
108
|
+
allowPrivateNetwork: record.allowPrivateNetwork === true,
|
|
109
|
+
};
|
|
59
110
|
}
|
|
60
111
|
|
|
61
112
|
export function getApiKey(cfg: ProviderConfig): string {
|
|
@@ -2,12 +2,15 @@ import { Readability } from "@mozilla/readability";
|
|
|
2
2
|
import { parseHTML } from "linkedom";
|
|
3
3
|
import TurndownService from "turndown";
|
|
4
4
|
import { extractPdfBuffer } from "./pdf.js";
|
|
5
|
-
import { errorMessage, isAbortError, isPdfUrl, isPdfContentType, assertHttpUrl } from "./utils.js";
|
|
5
|
+
import { errorMessage, isAbortError, isPdfUrl, isPdfContentType, assertHttpUrl, type HttpUrlOptions } from "./utils.js";
|
|
6
6
|
import type { ExtractedContent } from "./types.js";
|
|
7
7
|
|
|
8
8
|
const CONCURRENT_LIMIT = 3;
|
|
9
9
|
const MAX_FETCH_BYTES = 10 * 1024 * 1024; // 10 MB response cap
|
|
10
10
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
12
|
+
/** Ceiling on the number of URLs a single fetch_content call may include. */
|
|
13
|
+
export const MAX_FETCH_URLS = 20;
|
|
11
14
|
|
|
12
15
|
const td = new TurndownService({
|
|
13
16
|
headingStyle: "atx",
|
|
@@ -43,31 +46,53 @@ async function readBufferCapped(response: Response, maxBytes: number): Promise<A
|
|
|
43
46
|
|
|
44
47
|
export async function fetchAllContent(
|
|
45
48
|
urls: string[],
|
|
46
|
-
signal?: AbortSignal
|
|
49
|
+
signal?: AbortSignal,
|
|
50
|
+
opts: HttpUrlOptions = {},
|
|
51
|
+
maxUrls: number = MAX_FETCH_URLS,
|
|
47
52
|
): Promise<ExtractedContent[]> {
|
|
53
|
+
const bounded = urls.slice(0, maxUrls);
|
|
48
54
|
const results: ExtractedContent[] = [];
|
|
49
|
-
for (let i = 0; i <
|
|
55
|
+
for (let i = 0; i < bounded.length; i += CONCURRENT_LIMIT) {
|
|
50
56
|
if (signal?.aborted) break;
|
|
51
|
-
const batch =
|
|
52
|
-
const batchResults = await Promise.all(batch.map((url) => fetchOne(url, signal)));
|
|
57
|
+
const batch = bounded.slice(i, i + CONCURRENT_LIMIT);
|
|
58
|
+
const batchResults = await Promise.all(batch.map((url) => fetchOne(url, signal, opts)));
|
|
53
59
|
results.push(...batchResults);
|
|
54
60
|
}
|
|
55
61
|
return results;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Fetch with redirects followed manually so each hop is re-validated. The native
|
|
66
|
+
* `redirect: "follow"` would let a public URL bounce to a private/link-local host
|
|
67
|
+
* past the `assertHttpUrl` check.
|
|
68
|
+
*/
|
|
69
|
+
async function fetchWithRedirectValidation(
|
|
70
|
+
url: string,
|
|
71
|
+
fetchSignal: AbortSignal,
|
|
72
|
+
opts: HttpUrlOptions
|
|
73
|
+
): Promise<Response> {
|
|
74
|
+
let current = assertHttpUrl(url, opts);
|
|
75
|
+
const headers = {
|
|
76
|
+
"User-Agent": "Mozilla/5.0 (compatible; pi-web-access/1.0)",
|
|
77
|
+
Accept: "text/html,application/xhtml+xml,application/pdf,*/*",
|
|
78
|
+
};
|
|
79
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
80
|
+
const response = await fetch(current, { signal: fetchSignal, headers, redirect: "manual" });
|
|
81
|
+
if (response.status < 300 || response.status >= 400) return response;
|
|
82
|
+
const location = response.headers.get("location");
|
|
83
|
+
if (!location) return response; // 3xx without a Location: treat as final
|
|
84
|
+
await response.body?.cancel().catch(() => {});
|
|
85
|
+
current = assertHttpUrl(new URL(location, current).href, opts);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function fetchOne(url: string, signal?: AbortSignal, opts: HttpUrlOptions = {}): Promise<ExtractedContent> {
|
|
59
91
|
try {
|
|
60
|
-
const target = assertHttpUrl(url);
|
|
61
92
|
const fetchSignal = signal
|
|
62
93
|
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
|
|
63
94
|
: AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
64
|
-
const response = await
|
|
65
|
-
signal: fetchSignal,
|
|
66
|
-
headers: {
|
|
67
|
-
"User-Agent": "Mozilla/5.0 (compatible; pi-web-access/1.0)",
|
|
68
|
-
Accept: "text/html,application/xhtml+xml,application/pdf,*/*",
|
|
69
|
-
},
|
|
70
|
-
});
|
|
95
|
+
const response = await fetchWithRedirectValidation(url, fetchSignal, opts);
|
|
71
96
|
|
|
72
97
|
if (!response.ok) {
|
|
73
98
|
return { url, title: "", content: "", error: `HTTP ${response.status}: ${url}` };
|
|
@@ -14,7 +14,15 @@ import { loadConfig, resolveCaps } from "./config.js";
|
|
|
14
14
|
import type { StoredData } from "./types.js";
|
|
15
15
|
|
|
16
16
|
export default function (pi: ExtensionAPI) {
|
|
17
|
-
const
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
const caps = resolveCaps(config);
|
|
19
|
+
const allowPrivateNetwork = config.allowPrivateNetwork === true;
|
|
20
|
+
|
|
21
|
+
// Note shown to the model when the per-call URL/query cap drops some inputs.
|
|
22
|
+
const skippedNote = (dropped: number, limit: number, singular: string, plural: string) =>
|
|
23
|
+
dropped > 0
|
|
24
|
+
? `\n\n*Note: ${dropped} additional ${dropped === 1 ? singular : plural} beyond the per-call limit of ${limit} ${dropped === 1 ? "was" : "were"} skipped.*`
|
|
25
|
+
: "";
|
|
18
26
|
|
|
19
27
|
pi.on("session_start", async (_event, ctx) => {
|
|
20
28
|
restoreFromSession(ctx);
|
|
@@ -34,18 +42,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
42
|
),
|
|
35
43
|
}),
|
|
36
44
|
async execute(_toolCallId, params, signal) {
|
|
37
|
-
const
|
|
38
|
-
if (!
|
|
45
|
+
const rawQueries = params.queries ?? (params.query ? [params.query] : null);
|
|
46
|
+
if (!rawQueries?.length) {
|
|
39
47
|
return {
|
|
40
48
|
content: [{ type: "text", text: "Error: provide either query or queries." }],
|
|
41
49
|
isError: true,
|
|
42
50
|
details: {} as Record<string, unknown>,
|
|
43
51
|
};
|
|
44
52
|
}
|
|
53
|
+
const queryList = rawQueries.slice(0, caps.maxSearchQueries);
|
|
54
|
+
const dropped = rawQueries.length - queryList.length;
|
|
45
55
|
|
|
46
56
|
let results;
|
|
47
57
|
try {
|
|
48
|
-
|
|
58
|
+
// allSettled so one provider's throw (non-JSON body, missing key) cannot
|
|
59
|
+
// discard the sibling queries' results.
|
|
60
|
+
const settled = await Promise.allSettled(queryList.map((q) => searchWeb(q, signal)));
|
|
61
|
+
results = settled.map((s, i) =>
|
|
62
|
+
s.status === "fulfilled"
|
|
63
|
+
? s.value
|
|
64
|
+
: { query: queryList[i], answer: "", results: [], error: errorMessage(s.reason) }
|
|
65
|
+
);
|
|
49
66
|
} catch (err) {
|
|
50
67
|
return {
|
|
51
68
|
content: [{ type: "text", text: `Error: ${errorMessage(err)}` }],
|
|
@@ -70,8 +87,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
87
|
});
|
|
71
88
|
|
|
72
89
|
const body = truncate(sections.join("\n\n---\n\n"), caps.maxContentChars);
|
|
90
|
+
const note = skippedNote(dropped, caps.maxSearchQueries, "query", "queries");
|
|
73
91
|
return {
|
|
74
|
-
content: [{ type: "text", text: `${body}\n\n*responseId: ${id}*` }],
|
|
92
|
+
content: [{ type: "text", text: `${body}${note}\n\n*responseId: ${id}*` }],
|
|
75
93
|
details: { responseId: id, queryCount: results.length },
|
|
76
94
|
};
|
|
77
95
|
},
|
|
@@ -101,8 +119,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
let results;
|
|
122
|
+
const dropped = urlList.length > caps.maxFetchUrls ? urlList.length - caps.maxFetchUrls : 0;
|
|
104
123
|
try {
|
|
105
|
-
results = await fetchAllContent(urlList, signal);
|
|
124
|
+
results = await fetchAllContent(urlList, signal, { allowPrivateNetwork }, caps.maxFetchUrls);
|
|
106
125
|
} catch (err) {
|
|
107
126
|
return {
|
|
108
127
|
content: [{ type: "text", text: `Error: ${errorMessage(err)}` }],
|
|
@@ -123,8 +142,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
123
142
|
});
|
|
124
143
|
|
|
125
144
|
const body = truncate(sections.join("\n\n---\n\n"), caps.maxContentChars);
|
|
145
|
+
const note = skippedNote(dropped, caps.maxFetchUrls, "URL", "URLs");
|
|
126
146
|
return {
|
|
127
|
-
content: [{ type: "text", text: `${body}\n\n*responseId: ${id}*` }],
|
|
147
|
+
content: [{ type: "text", text: `${body}${note}\n\n*responseId: ${id}*` }],
|
|
128
148
|
details: { responseId: id, urlCount: results.length },
|
|
129
149
|
};
|
|
130
150
|
},
|
|
@@ -41,7 +41,12 @@ export async function searchGemini(
|
|
|
41
41
|
return { query, answer: "", results: [], error: errorMessage(err) };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
let data: GeminiResponse;
|
|
45
|
+
try {
|
|
46
|
+
data = (await response.json()) as GeminiResponse;
|
|
47
|
+
} catch {
|
|
48
|
+
return { query, answer: "", results: [], error: `Gemini API error: HTTP ${response.status} (non-JSON response)` };
|
|
49
|
+
}
|
|
45
50
|
|
|
46
51
|
if (!response.ok) {
|
|
47
52
|
const msg = data.error?.message ?? `HTTP ${response.status}`;
|
|
@@ -39,7 +39,12 @@ export async function searchTavily(
|
|
|
39
39
|
return { query, answer: "", results: [], error: errorMessage(err) };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
let data: TavilyResponse;
|
|
43
|
+
try {
|
|
44
|
+
data = (await response.json()) as TavilyResponse;
|
|
45
|
+
} catch {
|
|
46
|
+
return { query, answer: "", results: [], error: `Tavily API error: HTTP ${response.status} (non-JSON response)` };
|
|
47
|
+
}
|
|
43
48
|
|
|
44
49
|
if (!response.ok) {
|
|
45
50
|
return { query, answer: "", results: [], error: `Tavily API error: HTTP ${response.status}` };
|
|
@@ -23,8 +23,14 @@ export function storeResult(id: string, data: StoredData): void {
|
|
|
23
23
|
evictExpired();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function getResult(id: string): StoredData | null {
|
|
27
|
-
|
|
26
|
+
export function getResult(id: string, now: number = Date.now()): StoredData | null {
|
|
27
|
+
const data = store.get(id);
|
|
28
|
+
if (!data) return null;
|
|
29
|
+
if (now - data.timestamp >= CACHE_TTL_MS) {
|
|
30
|
+
store.delete(id);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export function clearStore(): void {
|