740 lines
29 KiB
TypeScript
740 lines
29 KiB
TypeScript
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
FAST_BROWSER_ALLOWED_ORIGINS,
|
|
FAST_BROWSER_CDP_URL,
|
|
FAST_BROWSER_PYTHON,
|
|
FAST_BROWSER_TEXT_MODEL,
|
|
FAST_BROWSER_TEXT_REASONING,
|
|
FAST_BROWSER_TYPESAFE_KEY_ENV,
|
|
FAST_BROWSER_TYPESAFE_KEY_FILE,
|
|
FAST_BROWSER_TYPESAFE_KEY_PATH,
|
|
FastBrowserConfigError,
|
|
inspectFastBrowserConfig,
|
|
inspectFastBrowserConfigSettings,
|
|
parseFastBrowserConfig,
|
|
readFastBrowserConfigFile,
|
|
removeFastBrowserConfigFile,
|
|
validateBrowserProfileName,
|
|
type ConfigOverrides,
|
|
type FastBrowserConfig,
|
|
type FastBrowserConfigSettings,
|
|
type FastBrowserConfigSource,
|
|
type FastBrowserBrowserConfig,
|
|
writeFastBrowserConfigFile,
|
|
} from "./config.ts";
|
|
import {
|
|
FAST_BROWSER_SETTING_IDS,
|
|
FastBrowserSettingsView,
|
|
type FastBrowserSettingId,
|
|
type FastBrowserSettingsViewEvent,
|
|
} from "./settings-view.ts";
|
|
|
|
const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
|
|
const MODEL_INHERIT = "(unset override — inherit)";
|
|
const CLOSE = "Close";
|
|
const MANUAL_SETUP = "Launch named profile for manual setup";
|
|
const AUTOMATED_LAUNCH = "Launch automated browser session";
|
|
const FAST_BROWSER_SESSION_MESSAGE = "fast-browser-session";
|
|
const MAX_CONFIG_STRING = 4_000;
|
|
|
|
let modelChoicesCache: WeakMap<object, string[]> = new WeakMap();
|
|
|
|
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
ctx.ui.notify(message, level);
|
|
}
|
|
|
|
function errorText(error: unknown): string {
|
|
if (error instanceof FastBrowserConfigError) return error.message;
|
|
return "Unable to read fast-browser configuration";
|
|
}
|
|
|
|
function launchErrorText(error: unknown): string {
|
|
if (error instanceof FastBrowserConfigError) return error.message;
|
|
return "Unable to launch fast-browser session; close other users of the named profile and check browser configuration";
|
|
}
|
|
|
|
function cloneValues(values: ConfigOverrides): ConfigOverrides {
|
|
return {
|
|
...values,
|
|
...(values.allowedOrigins === undefined
|
|
? {}
|
|
: { allowedOrigins: values.allowedOrigins === "*" ? "*" : [...values.allowedOrigins] }),
|
|
...(values.typesafeKey
|
|
? { typesafeKey: { ...values.typesafeKey } }
|
|
: {}),
|
|
...(values.browser ? { browser: { ...values.browser } } : {}),
|
|
};
|
|
}
|
|
|
|
function valuesEqual(left: unknown, right: unknown): boolean {
|
|
if (Object.is(left, right)) return true;
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
return left.every((value, index) => valuesEqual(value, right[index]));
|
|
}
|
|
if (left && right && typeof left === "object" && typeof right === "object") {
|
|
const leftObject = left as Record<string, unknown>;
|
|
const rightObject = right as Record<string, unknown>;
|
|
const keys = new Set([...Object.keys(leftObject), ...Object.keys(rightObject)]);
|
|
return [...keys].every((key) => valuesEqual(leftObject[key], rightObject[key]));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function configOverridesEqual(left: ConfigOverrides, right: ConfigOverrides): boolean {
|
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
return [...keys].every((key) => valuesEqual(
|
|
(left as Record<string, unknown>)[key],
|
|
(right as Record<string, unknown>)[key],
|
|
));
|
|
}
|
|
|
|
function sourceText(source: FastBrowserConfigSource | undefined): string {
|
|
if (!source) return "unknown";
|
|
if (source.path) return `${source.source} (${source.path})`;
|
|
return source.source;
|
|
}
|
|
|
|
function displayValue(key: keyof FastBrowserConfig, value: unknown): string {
|
|
if (value === undefined) return "(unset)";
|
|
if (key === "allowedOrigins") return value === "*" ? "All web origins" : JSON.stringify(value);
|
|
if (key === "typesafeKey" && value && typeof value === "object") {
|
|
const reference = value as { env?: string; file?: string; key?: string };
|
|
return reference.env
|
|
? `{ env: ${reference.env} }`
|
|
: `{ file: ${reference.file}${reference.key ? `, key: ${reference.key}` : ""} }`;
|
|
}
|
|
if (key === "browser" && value && typeof value === "object") {
|
|
const browser = value as FastBrowserBrowserConfig;
|
|
return JSON.stringify(browser);
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function viewMessage(settings: FastBrowserConfigSettings, config: FastBrowserConfig | undefined): string {
|
|
const values = settings.values;
|
|
const names: Array<keyof FastBrowserConfig> = [
|
|
"cdpUrl",
|
|
"allowedOrigins",
|
|
"python",
|
|
"textModel",
|
|
"textReasoning",
|
|
"typesafeKey",
|
|
"browser",
|
|
];
|
|
const lines = [
|
|
"Fast Browser configuration",
|
|
"Precedence: per-run override > project > global > environment > defaults",
|
|
`Project path: ${settings.projectPath}`,
|
|
`Global path: ${settings.globalPath}`,
|
|
];
|
|
for (const name of names) {
|
|
lines.push(`${name}: ${displayValue(name, values[name])} [${sourceText(settings.sources[name])}]`);
|
|
}
|
|
if (!config) lines.push("Status: incomplete — configure allowed origins and attach/launch settings before a tool call");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function boundedInput(value: string, field: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed.length > MAX_CONFIG_STRING || /[\0\r\n]/.test(trimmed)) {
|
|
throw new FastBrowserConfigError(`${field} must be a bounded non-empty value`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function validateOriginInput(value: string): FastBrowserConfig["allowedOrigins"] {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
throw new FastBrowserConfigError(`${FAST_BROWSER_ALLOWED_ORIGINS} must be '*' or a JSON array of exact origins`);
|
|
}
|
|
// Reuse the strict loader validator without reading or writing files. The
|
|
// endpoint is intentionally a harmless loopback placeholder for this check.
|
|
const env: NodeJS.ProcessEnv = {
|
|
[FAST_BROWSER_CDP_URL]: "http://127.0.0.1:9222",
|
|
[FAST_BROWSER_ALLOWED_ORIGINS]: JSON.stringify(parsed),
|
|
};
|
|
return parseFastBrowserConfig(env).allowedOrigins;
|
|
}
|
|
|
|
function modelChoices(ctx: ExtensionContext, current: string | undefined): string[] {
|
|
const registry = (ctx.modelRegistry as unknown as {
|
|
getAvailable?: () => Array<{ provider?: string; id?: string }>;
|
|
} | undefined) ?? {};
|
|
const registryObject = registry as object;
|
|
let choices = modelChoicesCache.get(registryObject);
|
|
if (!choices) {
|
|
const available = registry.getAvailable?.() ?? [];
|
|
choices = available
|
|
.map((model) => {
|
|
if (!model || typeof model.id !== "string" || !model.id.trim()) return undefined;
|
|
const provider = typeof model.provider === "string" && model.provider.trim()
|
|
? `${model.provider}/`
|
|
: "";
|
|
return `${provider}${model.id}`;
|
|
})
|
|
.filter((value): value is string => value !== undefined);
|
|
choices = [...new Set(choices)].sort();
|
|
modelChoicesCache.set(registryObject, choices);
|
|
}
|
|
const result = [MODEL_INHERIT, ...choices];
|
|
if (current && !result.includes(current)) result.splice(1, 0, current);
|
|
return result;
|
|
}
|
|
|
|
function applyDraftValue(draft: ConfigOverrides, key: keyof FastBrowserConfig, value: unknown): void {
|
|
if (value === undefined) delete (draft as Record<string, unknown>)[key];
|
|
else (draft as Record<string, unknown>)[key] = value;
|
|
}
|
|
|
|
async function editEndpoint(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const value = await ctx.ui.input("CDP endpoint (http loopback URL)", draft.cdpUrl ?? "http://127.0.0.1:9222");
|
|
if (value === undefined) return false;
|
|
try {
|
|
// Full validation also rejects credentials, non-loopback hosts, and paths.
|
|
parseFastBrowserConfig({
|
|
[FAST_BROWSER_CDP_URL]: value,
|
|
[FAST_BROWSER_ALLOWED_ORIGINS]: "[\"https://example.invalid\"]",
|
|
});
|
|
applyDraftValue(draft, "cdpUrl", boundedInput(value, FAST_BROWSER_CDP_URL));
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const ORIGIN_ALL = "All web origins (*)";
|
|
const ORIGIN_SPECIFIC = "Specific origins";
|
|
const ORIGIN_INHERIT = "(unset override — inherit)";
|
|
|
|
async function editOrigins(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const mode = await ctx.ui.select("Allowed origins", [ORIGIN_ALL, ORIGIN_SPECIFIC, ORIGIN_INHERIT]);
|
|
if (mode === undefined) return false;
|
|
if (mode === ORIGIN_ALL) {
|
|
applyDraftValue(draft, "allowedOrigins", "*");
|
|
return true;
|
|
}
|
|
if (mode === ORIGIN_INHERIT) {
|
|
applyDraftValue(draft, "allowedOrigins", undefined);
|
|
return true;
|
|
}
|
|
if (mode !== ORIGIN_SPECIFIC) {
|
|
notify(ctx, "Invalid allowed origins policy", "error");
|
|
return true;
|
|
}
|
|
const current = Array.isArray(draft.allowedOrigins) ? draft.allowedOrigins : [];
|
|
const value = await ctx.ui.input(
|
|
"Allowed origins (JSON array of exact http(s) origins)",
|
|
JSON.stringify(current),
|
|
);
|
|
if (value === undefined) return false;
|
|
try {
|
|
applyDraftValue(draft, "allowedOrigins", validateOriginInput(value));
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function editPython(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const value = await ctx.ui.input("Python executable (blank to use default)", draft.python ?? "");
|
|
if (value === undefined) return false;
|
|
try {
|
|
applyDraftValue(draft, "python", value.trim() ? boundedInput(value, "python") : undefined);
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function editModel(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const selected = await ctx.ui.select("Auxiliary text model", modelChoices(ctx, draft.textModel));
|
|
if (selected === undefined) return false;
|
|
applyDraftValue(draft, "textModel", selected === MODEL_INHERIT ? undefined : selected);
|
|
return true;
|
|
}
|
|
|
|
async function editReasoning(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const selected = await ctx.ui.select("Auxiliary text reasoning", [...REASONING_LEVELS]);
|
|
if (selected === undefined) return false;
|
|
if (!REASONING_LEVELS.includes(selected as typeof REASONING_LEVELS[number])) {
|
|
notify(ctx, "Invalid text reasoning level", "error");
|
|
return true;
|
|
}
|
|
applyDraftValue(draft, "textReasoning", selected);
|
|
return true;
|
|
}
|
|
|
|
function currentBrowser(draft: ConfigOverrides): FastBrowserBrowserConfig {
|
|
const browser = draft.browser;
|
|
return browser ? { ...browser } : { mode: "attach" };
|
|
}
|
|
|
|
async function editBrowserMode(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const browser = currentBrowser(draft);
|
|
const mode = await ctx.ui.select("Browser mode", ["attach", "launch"]);
|
|
if (mode === undefined) return false;
|
|
if (mode !== "attach" && mode !== "launch") {
|
|
notify(ctx, "Invalid browser mode", "error");
|
|
return true;
|
|
}
|
|
if (mode === browser.mode) {
|
|
// Re-selecting the current mode must not discard launch attributes staged
|
|
// from an inherited browser or previously saved in this scope.
|
|
applyDraftValue(draft, "browser", { ...browser });
|
|
} else if (mode === "attach") {
|
|
applyDraftValue(draft, "browser", { mode: "attach" });
|
|
} else {
|
|
applyDraftValue(draft, "browser", { mode: "launch", ...(browser.product ? { product: browser.product } : {}) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function editBrowserProduct(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const browser = currentBrowser(draft);
|
|
const product = await ctx.ui.select("Browser product for launch", ["vivaldi", "chrome", "chromium"]);
|
|
if (product === undefined) return false;
|
|
if (product !== "vivaldi" && product !== "chrome" && product !== "chromium") {
|
|
notify(ctx, "Firefox is not supported; use Vivaldi, Chrome, or Chromium", "error");
|
|
return true;
|
|
}
|
|
applyDraftValue(draft, "browser", { ...browser, product });
|
|
return true;
|
|
}
|
|
|
|
async function editBrowserExecutable(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const browser = currentBrowser(draft);
|
|
if (browser.mode === "attach") {
|
|
notify(ctx, "Executable path is a launch setting; switch browser mode to launch first", "warning");
|
|
return true;
|
|
}
|
|
const value = await ctx.ui.input("Browser executable path (blank for product default)", browser.executablePath ?? "");
|
|
if (value === undefined) return false;
|
|
try {
|
|
const executablePath = value.trim() ? boundedInput(value, "browser.executablePath") : undefined;
|
|
applyDraftValue(draft, "browser", {
|
|
...browser,
|
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
...(executablePath === undefined ? { executablePath: undefined } : {}),
|
|
});
|
|
const next = currentBrowser(draft);
|
|
if (next.executablePath === undefined) delete (next as unknown as Record<string, unknown>).executablePath;
|
|
applyDraftValue(draft, "browser", next);
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function editBrowserHeadless(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const browser = currentBrowser(draft);
|
|
if (browser.mode === "attach") {
|
|
notify(ctx, "Headless is a launch setting; switch browser mode to launch first", "warning");
|
|
return true;
|
|
}
|
|
const value = await ctx.ui.select("Launch browser headless", ["false", "true"]);
|
|
if (value === undefined) return false;
|
|
if (value !== "false" && value !== "true") {
|
|
notify(ctx, "Invalid headless setting", "error");
|
|
return true;
|
|
}
|
|
applyDraftValue(draft, "browser", { ...browser, headless: value === "true" });
|
|
return true;
|
|
}
|
|
|
|
async function editBrowserProfile(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const browser = currentBrowser(draft);
|
|
if (browser.mode === "attach") {
|
|
notify(ctx, "Profile is a launch setting; switch browser mode to launch first", "warning");
|
|
return true;
|
|
}
|
|
const value = await ctx.ui.input(
|
|
"Persistent browser profile name (blank for disposable)",
|
|
browser.profile ?? "",
|
|
);
|
|
if (value === undefined) return false;
|
|
try {
|
|
const profile = value.trim();
|
|
const next = { ...browser } as unknown as Record<string, unknown>;
|
|
if (profile) next.profile = validateBrowserProfileName(profile);
|
|
else delete next.profile;
|
|
applyDraftValue(draft, "browser", next);
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function editBrowserStartUrl(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const browser = currentBrowser(draft);
|
|
if (browser.mode === "attach") {
|
|
notify(ctx, "Start URL is a launch setting; switch browser mode to launch first", "warning");
|
|
return true;
|
|
}
|
|
const value = await ctx.ui.input("Launch start URL (must use an allowed origin)", browser.startUrl ?? "");
|
|
if (value === undefined) return false;
|
|
try {
|
|
const startUrl = value.trim() ? boundedInput(value, "browser.startUrl") : undefined;
|
|
if (startUrl) {
|
|
const parsed = new URL(startUrl);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new FastBrowserConfigError("browser.startUrl must be an http(s) URL");
|
|
if (parsed.username || parsed.password || parsed.hostname.includes("*")) throw new FastBrowserConfigError("browser.startUrl must not contain credentials or wildcards");
|
|
}
|
|
const next = { ...browser } as unknown as Record<string, unknown>;
|
|
if (startUrl) next.startUrl = startUrl;
|
|
else delete next.startUrl;
|
|
applyDraftValue(draft, "browser", next);
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function editTypesafeKey(ctx: ExtensionContext, draft: ConfigOverrides): Promise<boolean> {
|
|
const current = draft.typesafeKey;
|
|
const mode = await ctx.ui.select("Typesafe key source (reference only)", [
|
|
"(unset override — inherit)",
|
|
"Environment variable",
|
|
"File reference",
|
|
]);
|
|
if (mode === undefined) return false;
|
|
if (mode === "(unset override — inherit)") {
|
|
applyDraftValue(draft, "typesafeKey", undefined);
|
|
return true;
|
|
}
|
|
if (mode === "Environment variable") {
|
|
const name = await ctx.ui.input(
|
|
"Environment variable name (not the secret value)",
|
|
current && "env" in current ? current.env : "TYPESAFE_API_KEY",
|
|
);
|
|
if (name === undefined) return false;
|
|
try {
|
|
const env = boundedInput(name, "typesafeKey.env");
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(env)) throw new FastBrowserConfigError("typesafeKey.env must be an environment variable name");
|
|
applyDraftValue(draft, "typesafeKey", { env });
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const file = await ctx.ui.input(
|
|
"Credential JSON file path (the file contents are never displayed)",
|
|
current && "file" in current ? current.file : "",
|
|
);
|
|
if (file === undefined) return false;
|
|
const key = await ctx.ui.input(
|
|
"Credential JSON key (optional; this is not a password)",
|
|
current && "file" in current ? current.key ?? "" : "",
|
|
);
|
|
if (key === undefined) return false;
|
|
try {
|
|
const filePath = boundedInput(file, "typesafeKey.file");
|
|
const keyPath = key.trim() ? boundedInput(key, "typesafeKey.key") : undefined;
|
|
applyDraftValue(draft, "typesafeKey", keyPath ? { file: filePath, key: keyPath } : { file: filePath });
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function environmentInheritedValues(): ConfigOverrides {
|
|
const env = { ...process.env };
|
|
const hasCdp = Boolean(env[FAST_BROWSER_CDP_URL]?.trim());
|
|
const hasOrigins = Boolean(env[FAST_BROWSER_ALLOWED_ORIGINS]?.trim());
|
|
// parseFastBrowserConfig is deliberately reused with a harmless endpoint so
|
|
// this view validates exactly like the loader without resolving credentials.
|
|
if (!hasCdp) env[FAST_BROWSER_CDP_URL] = "http://127.0.0.1:9222";
|
|
const parsed = parseFastBrowserConfig(env);
|
|
const values: ConfigOverrides = {
|
|
textReasoning: parsed.textReasoning ?? "off",
|
|
allowedOrigins: hasOrigins ? parsed.allowedOrigins : "*",
|
|
};
|
|
if (hasCdp) values.cdpUrl = parsed.cdpUrl;
|
|
if (env[FAST_BROWSER_PYTHON]?.trim()) values.python = parsed.python;
|
|
if (env[FAST_BROWSER_TEXT_MODEL]?.trim()) values.textModel = parsed.textModel;
|
|
if (env[FAST_BROWSER_TEXT_REASONING]?.trim()) values.textReasoning = parsed.textReasoning;
|
|
|
|
const keyEnv = env[FAST_BROWSER_TYPESAFE_KEY_ENV]?.trim();
|
|
const keyFile = env[FAST_BROWSER_TYPESAFE_KEY_FILE]?.trim();
|
|
const keyPath = env[FAST_BROWSER_TYPESAFE_KEY_PATH]?.trim();
|
|
if (keyEnv) values.typesafeKey = { env: keyEnv };
|
|
else if (keyFile) values.typesafeKey = { file: keyFile, ...(keyPath ? { key: keyPath } : {}) };
|
|
return values;
|
|
}
|
|
|
|
function inheritedValues(settings: FastBrowserConfigSettings, scope: "global" | "project"): ConfigOverrides {
|
|
// Both scope editors need values below the scope being edited. Reconstruct
|
|
// that chain explicitly so an existing project override never appears as a
|
|
// global default, and unsetting a project draft reveals global/env values.
|
|
const inherited = environmentInheritedValues();
|
|
if (scope === "project") {
|
|
Object.assign(inherited, cloneValues(readFastBrowserConfigFile(settings.globalPath, "global")));
|
|
}
|
|
return inherited;
|
|
}
|
|
|
|
async function showScopeSettings(
|
|
ctx: ExtensionContext,
|
|
settings: FastBrowserConfigSettings,
|
|
scope: "global" | "project",
|
|
draft: ConfigOverrides,
|
|
): Promise<FastBrowserSettingsViewEvent | undefined> {
|
|
const inherited = inheritedValues(settings, scope);
|
|
return ctx.ui.custom<FastBrowserSettingsViewEvent | undefined>((tui, _theme, _keybindings, done) => {
|
|
const view = new FastBrowserSettingsView({
|
|
scope,
|
|
draft,
|
|
inherited,
|
|
onEvent: done,
|
|
});
|
|
return {
|
|
render: (width: number) => view.render(width),
|
|
handleInput: (data: string) => {
|
|
view.handleInput(data);
|
|
tui.requestRender();
|
|
},
|
|
invalidate: () => view.invalidate(),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function saveScope(
|
|
ctx: ExtensionContext,
|
|
path: string,
|
|
draft: ConfigOverrides,
|
|
): Promise<boolean> {
|
|
try {
|
|
// Scope files are intentionally partial. Required endpoint/origin checks
|
|
// happen only when the browser tool resolves the merged configuration.
|
|
writeFastBrowserConfigFile(path, draft);
|
|
return true;
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function editById(ctx: ExtensionContext, draft: ConfigOverrides, id: FastBrowserSettingId): Promise<boolean> {
|
|
switch (id) {
|
|
case FAST_BROWSER_SETTING_IDS.cdpUrl: return editEndpoint(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.allowedOrigins: return editOrigins(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.python: return editPython(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.textModel: return editModel(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.textReasoning: return editReasoning(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.typesafeKey: return editTypesafeKey(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.browserMode: return editBrowserMode(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.browserProduct: return editBrowserProduct(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.browserExecutable: return editBrowserExecutable(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.browserHeadless: return editBrowserHeadless(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.browserStartUrl: return editBrowserStartUrl(ctx, draft);
|
|
case FAST_BROWSER_SETTING_IDS.browserProfile: return editBrowserProfile(ctx, draft);
|
|
}
|
|
}
|
|
|
|
function isBrowserSetting(id: FastBrowserSettingId): boolean {
|
|
return id === FAST_BROWSER_SETTING_IDS.browserMode ||
|
|
id === FAST_BROWSER_SETTING_IDS.browserProduct ||
|
|
id === FAST_BROWSER_SETTING_IDS.browserExecutable ||
|
|
id === FAST_BROWSER_SETTING_IDS.browserHeadless ||
|
|
id === FAST_BROWSER_SETTING_IDS.browserStartUrl ||
|
|
id === FAST_BROWSER_SETTING_IDS.browserProfile;
|
|
}
|
|
|
|
function stageInheritedBrowser(
|
|
draft: ConfigOverrides,
|
|
inherited: ConfigOverrides,
|
|
id: FastBrowserSettingId,
|
|
): boolean {
|
|
if (isBrowserSetting(id) && draft.browser === undefined && inherited.browser !== undefined) {
|
|
draft.browser = { ...inherited.browser };
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function discardUnchangedInheritedBrowser(
|
|
draft: ConfigOverrides,
|
|
inherited: ConfigOverrides,
|
|
wasSeeded: boolean,
|
|
): void {
|
|
if (wasSeeded && valuesEqual(draft.browser, inherited.browser)) delete draft.browser;
|
|
}
|
|
|
|
async function editScope(ctx: ExtensionContext, settings: FastBrowserConfigSettings, scope: "global" | "project"): Promise<void> {
|
|
const path = scope === "global" ? settings.globalPath : settings.projectPath;
|
|
let draft: ConfigOverrides;
|
|
try {
|
|
draft = cloneValues(readFastBrowserConfigFile(path, scope));
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
return;
|
|
}
|
|
const inherited = inheritedValues(settings, scope);
|
|
while (true) {
|
|
const selected = await showScopeSettings(ctx, settings, scope, draft);
|
|
if (!selected || selected.kind === "cancel") return;
|
|
|
|
// Keep the displayed draft committed to disk. An edit dialog may be
|
|
// cancelled or rejected without changing either the file or this draft.
|
|
const staged = cloneValues(draft);
|
|
const inheritedBrowserSeeded = stageInheritedBrowser(staged, inherited, selected.id);
|
|
if (!(await editById(ctx, staged, selected.id))) continue;
|
|
discardUnchangedInheritedBrowser(staged, inherited, inheritedBrowserSeeded);
|
|
if (configOverridesEqual(draft, staged)) continue;
|
|
if (await saveScope(ctx, path, staged)) draft = staged;
|
|
}
|
|
}
|
|
|
|
async function resetProject(ctx: ExtensionContext, settings: FastBrowserConfigSettings): Promise<void> {
|
|
try {
|
|
removeFastBrowserConfigFile(settings.projectPath);
|
|
notify(ctx, `Reset project fast-browser override (${settings.projectPath})`, "info");
|
|
} catch (error) {
|
|
notify(ctx, errorText(error), "error");
|
|
}
|
|
}
|
|
|
|
export interface FastBrowserLaunchResult {
|
|
sessionId: string;
|
|
profile?: string;
|
|
}
|
|
|
|
export interface FastBrowserSessionMessage extends FastBrowserLaunchResult {
|
|
purpose: "setup" | "automation";
|
|
}
|
|
|
|
export interface FastBrowserCommandActions {
|
|
stopBrowser?: () => Promise<void>;
|
|
/** Launch a saved named profile for interactive setup or automation. */
|
|
launchBrowser?: (
|
|
ctx: ExtensionContext,
|
|
purpose: "setup" | "automation",
|
|
) => Promise<FastBrowserLaunchResult>;
|
|
/** Persist launch metadata in the conversation without triggering a turn. */
|
|
sendSessionMessage?: (message: FastBrowserSessionMessage) => void;
|
|
}
|
|
|
|
function sessionMessageContent(message: FastBrowserSessionMessage): string {
|
|
const details = JSON.stringify(message);
|
|
return message.purpose === "setup"
|
|
? `Fast Browser manual setup session is active: ${details}. This session is not automatable; close it fully before launching automation.`
|
|
: `Fast Browser automated session is ready: ${details}. Reuse its sessionId with fast_browser_observe or fast_browser_run.`;
|
|
}
|
|
|
|
async function runFastBrowserCommand(ctx: ExtensionContext, actions: FastBrowserCommandActions = {}): Promise<void> {
|
|
if (ctx.mode !== "tui") {
|
|
notify(ctx, "/fast-browser requires Pi TUI mode", "error");
|
|
return;
|
|
}
|
|
|
|
const cwd = ctx.cwd || process.cwd();
|
|
const readEffective = (): { settings: FastBrowserConfigSettings; config?: FastBrowserConfig } => {
|
|
const settings = inspectFastBrowserConfigSettings(cwd);
|
|
let config: FastBrowserConfig | undefined;
|
|
try { config = inspectFastBrowserConfig(cwd).config; } catch (error) {
|
|
if (!(error instanceof FastBrowserConfigError)) throw error;
|
|
}
|
|
return { settings, config };
|
|
};
|
|
|
|
let current: { settings: FastBrowserConfigSettings; config?: FastBrowserConfig };
|
|
try {
|
|
current = readEffective();
|
|
} catch (error) {
|
|
// Existing malformed files are never overwritten by a settings command.
|
|
notify(ctx, errorText(error), "error");
|
|
return;
|
|
}
|
|
|
|
const menu = [
|
|
"View effective configuration",
|
|
"Edit global settings",
|
|
"Edit project settings",
|
|
"Reset project override",
|
|
...(actions.launchBrowser ? [MANUAL_SETUP, AUTOMATED_LAUNCH] : []),
|
|
...(actions.stopBrowser ? ["Close all browser sessions in this Pi instance"] : []),
|
|
CLOSE,
|
|
];
|
|
while (true) {
|
|
const selected = await ctx.ui.select("Fast Browser settings", menu);
|
|
if (selected === undefined || selected === CLOSE) return;
|
|
if (selected === menu[0]) {
|
|
notify(ctx, viewMessage(current.settings, current.config), "info");
|
|
} else if (selected === menu[1] || selected === menu[2]) {
|
|
await editScope(ctx, current.settings, selected === menu[1] ? "global" : "project");
|
|
try { current = readEffective(); } catch (error) { notify(ctx, errorText(error), "error"); return; }
|
|
} else if (selected === menu[3]) {
|
|
await resetProject(ctx, current.settings);
|
|
try { current = readEffective(); } catch (error) { notify(ctx, errorText(error), "error"); return; }
|
|
} else if (actions.launchBrowser && (selected === MANUAL_SETUP || selected === AUTOMATED_LAUNCH)) {
|
|
try {
|
|
const purpose = selected === MANUAL_SETUP ? "setup" : "automation";
|
|
const launched = await actions.launchBrowser(ctx, purpose);
|
|
const metadata: FastBrowserSessionMessage = {
|
|
sessionId: launched.sessionId,
|
|
purpose,
|
|
...(launched.profile ? { profile: launched.profile } : {}),
|
|
};
|
|
actions.sendSessionMessage?.(metadata);
|
|
const profile = launched.profile ? ` profile ${launched.profile}` : "";
|
|
notify(
|
|
ctx,
|
|
purpose === "setup"
|
|
? `Launched manual browser setup${profile}; session ${launched.sessionId} is not automatable and must be closed before automation`
|
|
: `Launched automated browser${profile}; use session ${launched.sessionId} with fast_browser_observe or fast_browser_run`,
|
|
"info",
|
|
);
|
|
} catch (error) {
|
|
notify(ctx, launchErrorText(error), "error");
|
|
}
|
|
} else if (actions.stopBrowser && selected === "Close all browser sessions in this Pi instance") {
|
|
try {
|
|
await actions.stopBrowser();
|
|
notify(ctx, "Closed this Pi instance's browser sessions; external browser processes were left running", "info");
|
|
} catch {
|
|
notify(ctx, "Unable to close every browser session; cleanup can be retried", "error");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Register the native, bounded `/fast-browser` settings command. */
|
|
export function registerFastBrowserCommand(pi: ExtensionAPI, actions: FastBrowserCommandActions = {}): void {
|
|
const commandActions: FastBrowserCommandActions = actions.sendSessionMessage
|
|
? actions
|
|
: {
|
|
...actions,
|
|
sendSessionMessage: (message) => {
|
|
pi.sendMessage({
|
|
customType: FAST_BROWSER_SESSION_MESSAGE,
|
|
content: sessionMessageContent(message),
|
|
display: true,
|
|
// Keep details deliberately limited to the opaque session metadata;
|
|
// no resolved configuration or credential values cross this boundary.
|
|
details: message,
|
|
}, {
|
|
triggerTurn: false,
|
|
deliverAs: "nextTurn",
|
|
});
|
|
},
|
|
};
|
|
pi.registerCommand("fast-browser", {
|
|
description: "View and edit bounded fast-browser configuration",
|
|
handler: async (_args, ctx) => {
|
|
await runFastBrowserCommand(ctx, commandActions);
|
|
},
|
|
});
|
|
}
|
|
|
|
export default registerFastBrowserCommand;
|
|
|
|
// Test seam: clear only the local model catalogue cache; it never contacts a
|
|
// provider and is intentionally not exported from the extension entrypoint.
|
|
export function resetFastBrowserModelChoicesCacheForTests(): void {
|
|
modelChoicesCache = new WeakMap();
|
|
}
|