diff --git a/.gitignore b/.gitignore index 2e12dd6..1830146 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,8 @@ coverage/ *.tsbuildinfo # Test generated files -/test/fixtures/generated/ \ No newline at end of file +/test/fixtures/generated/ + +# Playwright outputs (Chromium extension E2E) +chromium-extension/test-results/ +chromium-extension/playwright-report/ diff --git a/README.md b/README.md index 31a0040..54acdc7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,13 @@ Join us and help shape the future of AI-powered browsing: We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on how to get started. +## SOCA Official Operations Guides + +- Markdown operations guide: `../../../RESSOURCES/guides/SOCA_HOLOBIONT_OS_OPENBROWSER_PROMPTBUDDY_OFFICIAL_GUIDE.md` +- HOLOS HTML guide: `../../../RESSOURCES/guides/SOCA_HOLOBIONT_OS_HOLOS_OPENBROWSER_GUIDE.html` +- Official guides index: `../../../RESSOURCES/guides/SOCA_OFFICIAL_GUIDES_INDEX.html` +- Bridge Prompt Buddy notes: `./bridge/PROMPTBUDDY.md` + ## License OpenBrowser is open source under MIT licence diff --git a/app_builder/README.md b/app_builder/README.md new file mode 100644 index 0000000..755f0d5 --- /dev/null +++ b/app_builder/README.md @@ -0,0 +1,24 @@ +# SOCA OpenBrowser App Builder (v1) + +This module implements a SOCA App Builder lane designed for **Best-of-N** candidate generation across multiple web builders (Google AI Studio Build, Lovable, Antigravity) with: + +- Deterministic, replayable **Action DSL** runs +- **Evidence-first** artifacts (screenshots, DOM snapshots, actions log, downloads, sha256 manifest) +- **Fail-closed** behavior (missing artifacts = FAIL) +- **HIL-gated** steps (explicit pauses for login/OAuth/export/download when required) + +Key files: + +- Blueprint (SSOT input contract): + - `SOCA_APP_BUILDER_BLUEPRINT.v1.json` +- Action specs (per builder): + - `actions/google_ai_studio_build.actions.v1.json` + - `actions/lovable.actions.v1.json` + - `actions/antigravity.actions.v1.json` +- Runner (Action DSL engine + evidence): + - `run_action_spec.ts` + +Notes: + +- The provided action specs are **templates** and may require selector tuning as vendor UIs evolve. +- This lane is designed to be extended with additional builders by adding new `actions/*.actions.v1.json` and adapter logic. diff --git a/app_builder/SOCA_APP_BUILDER_BLUEPRINT.v1.json b/app_builder/SOCA_APP_BUILDER_BLUEPRINT.v1.json new file mode 100644 index 0000000..83679c6 --- /dev/null +++ b/app_builder/SOCA_APP_BUILDER_BLUEPRINT.v1.json @@ -0,0 +1,133 @@ +{ + "schema_version": "1.0", + "kind": "SOCA_APP_BUILDER_BLUEPRINT", + "id": "soca_app_builder_v1", + "policy": { + "lane": "L1_ASSISTED", + "network": "ALLOW_BUILDER_UI_ONLY", + "ssot_write": false, + "hil_required_for": [ + "login_or_oauth", + "grant_browser_permissions", + "connect_github", + "download_or_export_code", + "run_external_commands" + ] + }, + "inputs": { + "app_name": "NT2L", + "app_codename": "nt2l", + "primary_blueprint_files": [ + "NT2L_APP_BLUEPRINT.json", + "NT2L_WORKFLOW_SCHEMA.json" + ], + "prompt_pack": { + "global_system_instructions": "SYSTEM_INSTRUCTIONS.txt", + "builder_first_message": "FIRST_MESSAGE.txt", + "post_export_fixup_prompt": "FIXUP_PROMPT.txt" + } + }, + "builders": [ + { + "id": "google_ai_studio_build", + "label": "Google AI Studio Build", + "type": "web_builder", + "url": "https://aistudio.google.com/apps", + "allowed_domains": ["aistudio.google.com", "accounts.google.com"], + "export_methods": ["download_zip", "github_sync"], + "adapter": { + "action_spec_template": "actions/google_ai_studio_build.actions.v1.json", + "ui_contract": { + "system_instructions_panel": "advanced_settings", + "build_button_text": "Build", + "download_button_text": "Download" + } + } + }, + { + "id": "lovable", + "label": "Lovable", + "type": "web_builder", + "url": "https://lovable.dev", + "allowed_domains": ["lovable.dev", "github.com"], + "export_methods": ["github_sync", "download_zip"], + "adapter": { + "action_spec_template": "actions/lovable.actions.v1.json", + "ui_contract": { + "new_project": "New project", + "export_to_github": "Connect to GitHub", + "download_zip": "Download" + } + } + }, + { + "id": "antigravity", + "label": "Antigravity", + "type": "agent_ide", + "url": "https://antigravity.google/", + "allowed_domains": ["antigravity.google"], + "export_methods": ["github_sync", "export_zip"], + "adapter": { + "mode": "workspace_isolated", + "artifacts_required": [ + "task_list", + "implementation_plan", + "screenshots", + "recordings" + ], + "danger_mode": "OFF" + } + } + ], + "best_of_n": { + "n_per_builder": 3, + "variant_strategy": { + "prompt_variants": ["baseline", "strict_nextjs_only", "a11y_extreme"], + "temperature": [0.1, 0.2, 0.3] + } + }, + "post_export": { + "normalize": { + "target_stack": "nextjs_app_router_ts", + "if_builder_outputs_vite_react": "run_fixup_prompt_then_codex_patchset" + }, + "quality_gates": [ + "dependency_install", + "lint", + "typecheck", + "unit_tests", + "a11y_smoke", + "security_audit" + ], + "scoring": { + "weights": { + "build_success": 40, + "tests_pass": 20, + "a11y": 15, + "security": 15, + "maintainability": 10 + }, + "min_requirements": { + "build_success": true, + "no_critical_vulns": true, + "a11y_smoke_pass": true + } + } + }, + "evidence": { + "bundle_format": "SOCA_EVIDENCE_BUNDLE_V1", + "required_artifacts": [ + "rendered_prompts", + "actions_log_jsonl", + "dom_snapshots", + "screenshots", + "downloaded_artifacts", + "sha256_manifest" + ] + }, + "outputs": { + "runs_dir": "runs/app_builder", + "emit_candidate_bundles": true, + "emit_best_candidate_pointer": true + } +} diff --git a/app_builder/actions/antigravity.actions.v1.json b/app_builder/actions/antigravity.actions.v1.json new file mode 100644 index 0000000..b70b31d --- /dev/null +++ b/app_builder/actions/antigravity.actions.v1.json @@ -0,0 +1,32 @@ +{ + "schema_version": "1.0", + "builder_id": "antigravity", + "steps": [ + { "op": "open_url", "url": "https://antigravity.google/" }, + { + "op": "hil_pause", + "reason": "User must be logged in to Antigravity and open an isolated workspace before continuing." + }, + + { "op": "wait_for_selector", "selector": "body", "timeout_ms": 60000 }, + { "op": "screenshot", "label": "antigravity_loaded" }, + + { + "op": "hil_pause", + "reason": "Run the Antigravity task to generate the candidate. Ensure required artifacts are produced (task list, implementation plan, screenshots/recordings)." + }, + { "op": "screenshot", "label": "antigravity_after_generation" }, + + { + "op": "hil_pause", + "reason": "HIL required to export code (ZIP and/or GitHub). Trigger an export that downloads a ZIP to continue." + }, + { + "op": "download_click_and_capture", + "label": "antigravity_zip_export", + "selector": "button:has-text('Export'), button:has-text('Download'), a:has-text('Download')" + }, + + { "op": "read_dom_snapshot", "label": "final_dom" } + ] +} diff --git a/app_builder/actions/google_ai_studio_build.actions.v1.json b/app_builder/actions/google_ai_studio_build.actions.v1.json new file mode 100644 index 0000000..1121da7 --- /dev/null +++ b/app_builder/actions/google_ai_studio_build.actions.v1.json @@ -0,0 +1,52 @@ +{ + "schema_version": "1.0", + "builder_id": "google_ai_studio_build", + "steps": [ + { "op": "open_url", "url": "https://aistudio.google.com/apps" }, + { + "op": "hil_pause", + "reason": "User must be logged in to Google if not already." + }, + + { "op": "wait_for_text", "text": "Build", "timeout_ms": 30000 }, + { "op": "screenshot", "label": "aistudio_apps_loaded" }, + + { + "op": "click", + "selector": "[aria-label*='Advanced settings'], [aria-label*='Settings'], button[title*='Settings']" + }, + { + "op": "wait_for_text", + "text": "System instructions", + "timeout_ms": 30000 + }, + + { + "op": "paste_large_text", + "target": "system_instructions", + "from_file": "SYSTEM_INSTRUCTIONS.txt" + }, + { + "op": "upload_files", + "files": ["NT2L_APP_BLUEPRINT.json", "NT2L_WORKFLOW_SCHEMA.json"] + }, + + { + "op": "paste_large_text", + "target": "main_prompt", + "from_file": "FIRST_MESSAGE.txt" + }, + { "op": "click", "selector": "button:has-text('Build')" }, + + { "op": "wait_for_text", "text": "Checkpoint", "timeout_ms": 240000 }, + { "op": "screenshot", "label": "build_complete" }, + + { + "op": "click", + "selector": "button[title*='Download'], button:has-text('Download')" + }, + { "op": "download_click_and_capture", "label": "aistudio_zip_export" }, + + { "op": "read_dom_snapshot", "label": "final_dom" } + ] +} diff --git a/app_builder/actions/lovable.actions.v1.json b/app_builder/actions/lovable.actions.v1.json new file mode 100644 index 0000000..a544ec0 --- /dev/null +++ b/app_builder/actions/lovable.actions.v1.json @@ -0,0 +1,58 @@ +{ + "schema_version": "1.0", + "builder_id": "lovable", + "steps": [ + { "op": "open_url", "url": "https://lovable.dev" }, + { + "op": "hil_pause", + "reason": "User must be logged in to Lovable if not already." + }, + + { "op": "wait_for_text", "text": "New project", "timeout_ms": 60000 }, + { "op": "screenshot", "label": "lovable_loaded" }, + + { "op": "click", "selector": "text=New project" }, + { + "op": "wait_for_selector", + "selector": "textarea, [contenteditable='true']", + "timeout_ms": 60000 + }, + + { + "op": "paste_large_text", + "target": "main_prompt", + "from_file": "FIRST_MESSAGE.txt" + }, + { + "op": "click", + "selector": "button:has-text('Create'), button:has-text('Generate'), button:has-text('Build')" + }, + + { + "op": "wait_for_text", + "text": "Connect to GitHub", + "timeout_ms": 240000 + }, + { "op": "screenshot", "label": "lovable_ready_to_export" }, + + { + "op": "hil_pause", + "reason": "HIL required to connect GitHub and/or authorize export." + }, + + { "op": "click", "selector": "text=Connect to GitHub" }, + { + "op": "hil_pause", + "reason": "Complete GitHub OAuth/connection in the browser, then continue." + }, + + { "op": "wait_for_text", "text": "Download", "timeout_ms": 240000 }, + { + "op": "download_click_and_capture", + "label": "lovable_zip_export", + "selector": "button:has-text('Download'), a:has-text('Download')" + }, + + { "op": "read_dom_snapshot", "label": "final_dom" } + ] +} diff --git a/app_builder/run_action_spec.ts b/app_builder/run_action_spec.ts new file mode 100644 index 0000000..f5193ea --- /dev/null +++ b/app_builder/run_action_spec.ts @@ -0,0 +1,682 @@ +import { chromium, type Page, type Locator, type Download } from "playwright"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import readline from "node:readline/promises"; + +type JsonObject = Record; + +type ActionStep = + | { op: "open_url"; url: string } + | { op: "wait_for_selector"; selector: string; timeout_ms?: number } + | { op: "wait_for_text"; text: string; timeout_ms?: number } + | { op: "click"; selector: string; timeout_ms?: number } + | { op: "type"; selector: string; text: string; timeout_ms?: number } + | { + op: "paste_large_text"; + target?: string; + selector?: string; + from_file: string; + timeout_ms?: number; + } + | { + op: "upload_files"; + selector?: string; + files: string[]; + timeout_ms?: number; + } + | { op: "read_dom_snapshot"; label: string } + | { op: "screenshot"; label: string; full_page?: boolean } + | { + op: "download_click_and_capture"; + label: string; + selector?: string; + timeout_ms?: number; + } + | { op: "assert"; selector?: string; text?: string; timeout_ms?: number } + | { op: "hil_pause"; reason: string }; + +type ActionSpec = { + schema_version: string; + builder_id: string; + steps: ActionStep[]; +}; + +type CliArgs = { + actionSpecPath: string; + inputsDir: string; + runsRoot: string; + runDir?: string; + headless: boolean; + jsonStdout: boolean; + slowMoMs: number; +}; + +function utcTsCompact(): string { + // YYYYMMDDTHHMMSSZ + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + String(d.getUTCFullYear()) + + pad(d.getUTCMonth() + 1) + + pad(d.getUTCDate()) + + "T" + + pad(d.getUTCHours()) + + pad(d.getUTCMinutes()) + + pad(d.getUTCSeconds()) + + "Z" + ); +} + +function randHex(bytes = 4): string { + return crypto.randomBytes(bytes).toString("hex"); +} + +function truthyEnv(name: string): boolean { + const v = String(process.env[name] || "") + .trim() + .toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "y" || v === "on"; +} + +async function sha256File(filePath: string): Promise { + const h = crypto.createHash("sha256"); + await new Promise((resolve, reject) => { + const s = createReadStream(filePath); + s.on("data", (chunk) => h.update(chunk)); + s.on("end", () => resolve()); + s.on("error", reject); + }); + return h.digest("hex"); +} + +async function writeJson(filePath: string, obj: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(obj, null, 2) + "\n", "utf-8"); +} + +async function writeText(filePath: string, text: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, text, "utf-8"); +} + +async function appendJsonl(filePath: string, obj: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.appendFile(filePath, JSON.stringify(obj) + "\n", "utf-8"); +} + +function sanitizeFilename(name: string): string { + const base = String(name || "").trim() || "download.bin"; + // Keep conservative: ASCII-ish and filesystem safe. + return base + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") + .replace(/^\.+/, "_") + .slice(0, 180); +} + +async function ensureEmptyDir(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }); +} + +async function loadActionSpec(actionSpecPath: string): Promise { + const raw = await fs.readFile(actionSpecPath, "utf-8"); + const parsed = JSON.parse(raw) as JsonObject; + const schema_version = String(parsed.schema_version || "").trim(); + const builder_id = String(parsed.builder_id || "").trim(); + const steps = parsed.steps; + if (!schema_version || !builder_id || !Array.isArray(steps)) { + throw new Error("invalid_action_spec"); + } + return parsed as unknown as ActionSpec; +} + +function parseArgs(argv: string[]): CliArgs { + const args: Partial = { + headless: false, + jsonStdout: false, + slowMoMs: 0 + }; + + const next = (i: number) => { + if (i + 1 >= argv.length) throw new Error(`missing_value_for:${argv[i]}`); + return argv[i + 1]; + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--action-spec") { + args.actionSpecPath = next(i); + i++; + } else if (a === "--inputs-dir") { + args.inputsDir = next(i); + i++; + } else if (a === "--runs-root") { + args.runsRoot = next(i); + i++; + } else if (a === "--run-dir") { + args.runDir = next(i); + i++; + } else if (a === "--headless") { + args.headless = true; + } else if (a === "--json") { + args.jsonStdout = true; + } else if (a === "--slowmo-ms") { + args.slowMoMs = Number(next(i)); + i++; + } else if (a === "--help" || a === "-h") { + const msg = + "Usage: tsx run_action_spec.ts --action-spec --inputs-dir [--runs-root runs/app_builder] [--run-dir ] [--headless] [--json] [--slowmo-ms ]\n"; + process.stdout.write(msg); + process.exit(0); + } + } + + if (!args.actionSpecPath) throw new Error("missing --action-spec"); + if (!args.inputsDir) throw new Error("missing --inputs-dir"); + if (!args.runsRoot) args.runsRoot = "runs/app_builder"; + + return args as CliArgs; +} + +async function hilPause(reason: string, evidenceDir: string): Promise { + const auto = truthyEnv("SOCA_HIL_AUTO"); + const entry = { + type: "hil_pause", + reason, + auto, + at_utc: new Date().toISOString() + }; + await appendJsonl(path.join(evidenceDir, "actions_log.jsonl"), entry); + + if (auto) return; + if (!process.stdin.isTTY) { + throw new Error("hil_required_no_tty"); + } + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + try { + const answer = ( + await rl.question( + `HIL required: ${reason}\nType CONTINUE to proceed, or anything else to abort: ` + ) + ) + .trim() + .toUpperCase(); + if (answer !== "CONTINUE") { + throw new Error("hil_abort"); + } + } finally { + rl.close(); + } +} + +async function resolveTargetLocator( + page: Page, + builderId: string, + target: string +): Promise { + const t = String(target || "").trim(); + if (!t) throw new Error("missing_target"); + + // Conservative, fail-closed: if heuristic resolution finds nothing, we error. + // NOTE: these are intentionally minimal and may need tuning per vendor UI. + if (builderId === "google_ai_studio_build") { + if (t === "system_instructions") { + const byLabel = page.getByLabel(/system instructions/i); + if ((await byLabel.count()) > 0) return byLabel.first(); + const byText = page.getByText(/system instructions/i); + if ((await byText.count()) > 0) { + const near = byText.first().locator("xpath=following::textarea[1]"); + if ((await near.count()) > 0) return near.first(); + } + const anyTextarea = page.locator("textarea"); + if ((await anyTextarea.count()) > 0) return anyTextarea.first(); + throw new Error("target_not_found:system_instructions"); + } + if (t === "main_prompt") { + const anyTextarea = page.locator("textarea"); + const count = await anyTextarea.count(); + if (count > 0) return anyTextarea.nth(count - 1); + const editable = page.locator("[contenteditable='true']"); + if ((await editable.count()) > 0) return editable.first(); + throw new Error("target_not_found:main_prompt"); + } + } + + if (builderId === "lovable") { + if (t === "main_prompt") { + const anyTextarea = page.locator("textarea"); + if ((await anyTextarea.count()) > 0) return anyTextarea.first(); + const editable = page.locator("[contenteditable='true']"); + if ((await editable.count()) > 0) return editable.first(); + throw new Error("target_not_found:lovable_main_prompt"); + } + } + + throw new Error(`unsupported_target:${builderId}:${t}`); +} + +async function setEditable( + locator: Locator, + text: string, + timeoutMs?: number +): Promise { + await locator.scrollIntoViewIfNeeded({ timeout: timeoutMs ?? 30_000 }); + await locator.click({ timeout: timeoutMs ?? 30_000 }); + // Playwright supports fill on textarea/input/contenteditable. + await locator.fill(text, { timeout: timeoutMs ?? 30_000 }); +} + +async function copyInputFile(srcPath: string, dstPath: string): Promise { + await fs.mkdir(path.dirname(dstPath), { recursive: true }); + await fs.copyFile(srcPath, dstPath); +} + +async function writeSha256Manifest(evidenceDir: string): Promise { + const entries: Array<{ rel: string; abs: string }> = []; + async function walk(dir: string) { + const list = await fs.readdir(dir, { withFileTypes: true }); + for (const ent of list) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { + await walk(p); + } else if (ent.isFile()) { + if (ent.name === "sha256.txt") continue; + entries.push({ + abs: p, + rel: path.relative(evidenceDir, p).split(path.sep).join("/") + }); + } + } + } + await walk(evidenceDir); + entries.sort((a, b) => a.rel.localeCompare(b.rel)); + const lines: string[] = []; + for (const e of entries) { + const h = await sha256File(e.abs); + lines.push(`${h} ${e.rel}`); + } + const manifestPath = path.join(evidenceDir, "sha256.txt"); + await writeText(manifestPath, lines.length ? lines.join("\n") + "\n" : ""); + return manifestPath; +} + +async function validateEvidenceRequiredArtifacts( + evidenceDir: string +): Promise<{ ok: boolean; missing: string[] }> { + const missing: string[] = []; + const existsNonEmptyDir = async (p: string) => { + try { + const st = await fs.stat(p); + if (!st.isDirectory()) return false; + const files = (await fs.readdir(p)).filter((x) => x !== ".DS_Store"); + return files.length > 0; + } catch { + return false; + } + }; + const existsNonEmptyFile = async (p: string) => { + try { + const st = await fs.stat(p); + return st.isFile() && st.size > 0; + } catch { + return false; + } + }; + + if (!(await existsNonEmptyDir(path.join(evidenceDir, "rendered_prompts")))) + missing.push("rendered_prompts"); + if (!(await existsNonEmptyFile(path.join(evidenceDir, "actions_log.jsonl")))) + missing.push("actions_log_jsonl"); + if (!(await existsNonEmptyDir(path.join(evidenceDir, "dom_snapshots")))) + missing.push("dom_snapshots"); + if (!(await existsNonEmptyDir(path.join(evidenceDir, "screenshots")))) + missing.push("screenshots"); + if ( + !(await existsNonEmptyDir(path.join(evidenceDir, "downloaded_artifacts"))) + ) + missing.push("downloaded_artifacts"); + if (!(await existsNonEmptyFile(path.join(evidenceDir, "sha256.txt")))) + missing.push("sha256_manifest"); + + return { ok: missing.length === 0, missing }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const actionSpecPath = path.resolve(args.actionSpecPath); + const inputsDir = path.resolve(args.inputsDir); + const runsRoot = path.resolve(args.runsRoot); + + const spec = await loadActionSpec(actionSpecPath); + + const runDir = + args.runDir && String(args.runDir).trim() + ? path.resolve(args.runDir) + : path.join( + runsRoot, + new Date().toISOString().slice(0, 10).replaceAll("-", "/"), + `${utcTsCompact()}-${spec.builder_id}-${randHex(4)}` + ); + const evidenceDir = path.join(runDir, "evidence"); + + await ensureEmptyDir(evidenceDir); + await ensureEmptyDir(path.join(evidenceDir, "rendered_prompts")); + await ensureEmptyDir(path.join(evidenceDir, "uploaded_inputs")); + await ensureEmptyDir(path.join(evidenceDir, "dom_snapshots")); + await ensureEmptyDir(path.join(evidenceDir, "screenshots")); + await ensureEmptyDir(path.join(evidenceDir, "downloaded_artifacts")); + + const startedUtc = new Date().toISOString(); + await writeJson(path.join(evidenceDir, "run_context.json"), { + schema_version: "soca.openbrowser.app_builder.run_context.v1", + created_utc: startedUtc, + action_spec_path: actionSpecPath, + inputs_dir: inputsDir, + builder_id: spec.builder_id, + headless: args.headless, + slowMoMs: args.slowMoMs, + env_context: { + approval_policy: process.env.SOCA_APPROVAL_POLICY || "UNKNOWN", + sandbox_mode: process.env.SOCA_SANDBOX_MODE || "UNKNOWN", + network_access: process.env.SOCA_NETWORK_ACCESS || "UNKNOWN", + hil_auto: truthyEnv("SOCA_HIL_AUTO") + } + }); + await writeJson(path.join(evidenceDir, "action_spec.json"), spec); + + const actionsLogPath = path.join(evidenceDir, "actions_log.jsonl"); + await writeText(actionsLogPath, ""); + + const downloads: Array<{ + label: string; + filename: string; + path: string; + sha256: string; + }> = []; + let failure: { step_index: number; op: string; error: string } | null = null; + let lastClickSelector: string | null = null; + + const browser = await chromium.launch({ + headless: args.headless, + slowMo: args.slowMoMs > 0 ? args.slowMoMs : undefined + }); + + const context = await browser.newContext({ + acceptDownloads: true + }); + const page = await context.newPage(); + + const stepTimeoutDefault = 30_000; + + const recordStep = async (payload: JsonObject) => { + await appendJsonl(actionsLogPath, payload); + }; + + const captureErrorArtifacts = async (stepIndex: number) => { + try { + const label = `error_step_${String(stepIndex).padStart(3, "0")}`; + const shotPath = path.join(evidenceDir, "screenshots", `${label}.png`); + await page.screenshot({ path: shotPath, fullPage: true }); + const htmlPath = path.join(evidenceDir, "dom_snapshots", `${label}.html`); + const html = await page.content(); + await writeText(htmlPath, html); + } catch { + // Best effort. + } + }; + + try { + for (let i = 0; i < spec.steps.length; i++) { + if (failure) break; + const step = spec.steps[i]; + const op = (step as any).op as string; + const started = Date.now(); + const startedIso = new Date().toISOString(); + + const baseLog: JsonObject = { + type: "action_step", + step_index: i, + op, + started_utc: startedIso + }; + + try { + if (op === "open_url") { + const url = (step as any).url as string; + if (!url) throw new Error("missing_url"); + await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 90_000 + }); + } else if (op === "wait_for_selector") { + const selector = (step as any).selector as string; + const timeout = (step as any).timeout_ms ?? stepTimeoutDefault; + await page.waitForSelector(selector, { timeout }); + } else if (op === "wait_for_text") { + const text = (step as any).text as string; + const timeout = (step as any).timeout_ms ?? 60_000; + await page.waitForFunction( + (t) => { + const body = document.body; + if (!body) return false; + const s = body.innerText || ""; + return s.includes(String(t)); + }, + text, + { timeout } + ); + } else if (op === "click") { + const selector = (step as any).selector as string; + const timeout = (step as any).timeout_ms ?? stepTimeoutDefault; + lastClickSelector = selector; + await page.locator(selector).first().click({ timeout }); + } else if (op === "type") { + const selector = (step as any).selector as string; + const text = (step as any).text as string; + const timeout = (step as any).timeout_ms ?? stepTimeoutDefault; + const loc = page.locator(selector).first(); + await setEditable(loc, text, timeout); + } else if (op === "paste_large_text") { + const fromFile = (step as any).from_file as string; + const timeout = (step as any).timeout_ms ?? stepTimeoutDefault; + const abs = path.join(inputsDir, fromFile); + const text = await fs.readFile(abs, "utf-8"); + await copyInputFile( + abs, + path.join(evidenceDir, "rendered_prompts", path.basename(fromFile)) + ); + + let loc: Locator | null = null; + const selector = (step as any).selector as string | undefined; + const target = (step as any).target as string | undefined; + if (selector) { + loc = page.locator(selector).first(); + } else if (target) { + loc = await resolveTargetLocator(page, spec.builder_id, target); + } else { + throw new Error("paste_large_text_requires_selector_or_target"); + } + await setEditable(loc, text, timeout); + } else if (op === "upload_files") { + const files = ((step as any).files || []) as string[]; + if (!Array.isArray(files) || files.length === 0) + throw new Error("upload_files_missing_files"); + const timeout = (step as any).timeout_ms ?? 60_000; + const selector = (step as any).selector as string | undefined; + const loc = selector + ? page.locator(selector).first() + : page.locator("input[type='file']").first(); + const absFiles: string[] = []; + for (const f of files) { + const abs = path.join(inputsDir, f); + absFiles.push(abs); + await copyInputFile( + abs, + path.join(evidenceDir, "uploaded_inputs", path.basename(f)) + ); + } + await loc.setInputFiles(absFiles, { timeout }); + } else if (op === "read_dom_snapshot") { + const label = (step as any).label as string; + if (!label) throw new Error("missing_label"); + const html = await page.content(); + const outPath = path.join( + evidenceDir, + "dom_snapshots", + `${sanitizeFilename(label)}.html` + ); + await writeText(outPath, html); + } else if (op === "screenshot") { + const label = (step as any).label as string; + const fullPage = Boolean((step as any).full_page); + if (!label) throw new Error("missing_label"); + const outPath = path.join( + evidenceDir, + "screenshots", + `${sanitizeFilename(label)}.png` + ); + await page.screenshot({ path: outPath, fullPage }); + } else if (op === "download_click_and_capture") { + const label = (step as any).label as string; + const selector = (step as any).selector as string | undefined; + const timeout = (step as any).timeout_ms ?? 180_000; + if (!label) throw new Error("missing_label"); + + // HIL-gated by default because downloads are explicit policy gate in the lane. + await hilPause(`download_or_export_code: ${label}`, evidenceDir); + + const clickSelector = selector || lastClickSelector; + if (!clickSelector) { + throw new Error("download_click_and_capture_missing_selector"); + } + const downloadPromise = page.waitForEvent("download", { timeout }); + await page + .locator(clickSelector) + .first() + .click({ timeout: stepTimeoutDefault }); + const download = await downloadPromise; + const saved = await saveDownload(download, evidenceDir, label); + downloads.push(saved); + } else if (op === "assert") { + const selector = (step as any).selector as string | undefined; + const text = (step as any).text as string | undefined; + const timeout = (step as any).timeout_ms ?? stepTimeoutDefault; + if (selector) { + const count = await page.locator(selector).count(); + if (count <= 0) + throw new Error(`assert_failed_selector:${selector}`); + } else if (text) { + await page.waitForFunction( + (t) => (document.body?.innerText || "").includes(String(t)), + text, + { timeout } + ); + } else { + throw new Error("assert_requires_selector_or_text"); + } + } else if (op === "hil_pause") { + const reason = (step as any).reason as string; + await hilPause(reason || "HIL checkpoint", evidenceDir); + } else { + throw new Error(`unsupported_op:${op}`); + } + + const endedIso = new Date().toISOString(); + await recordStep({ + ...baseLog, + ok: true, + ended_utc: endedIso, + duration_ms: Date.now() - started + }); + } catch (e: any) { + const endedIso = new Date().toISOString(); + const msg = String(e?.message || e); + await captureErrorArtifacts(i); + await recordStep({ + ...baseLog, + ok: false, + ended_utc: endedIso, + duration_ms: Date.now() - started, + error: msg + }); + failure = { step_index: i, op, error: msg }; + // Fail-closed: stop the run after first failure, but still emit summary + manifest. + break; + } + } + } finally { + await browser.close().catch(() => {}); + } + + const endedUtc = new Date().toISOString(); + await writeSha256Manifest(evidenceDir); + const required = await validateEvidenceRequiredArtifacts(evidenceDir); + + const summary = { + ok: failure === null && required.ok, + builder_id: spec.builder_id, + started_utc: startedUtc, + ended_utc: endedUtc, + run_dir: runDir, + evidence_dir: evidenceDir, + downloads, + failure, + evidence_required: required + }; + await writeJson(path.join(evidenceDir, "run_summary.json"), summary); + + if (args.jsonStdout) { + process.stdout.write(JSON.stringify(summary) + "\n"); + } else { + process.stdout.write(`${summary.ok ? "OK" : "FAIL"}: ${runDir}\n`); + } + + if (!summary.ok) { + process.exitCode = 2; + } +} + +async function saveDownload( + download: Download, + evidenceDir: string, + label: string +): Promise<{ label: string; filename: string; path: string; sha256: string }> { + const suggested = sanitizeFilename(download.suggestedFilename()); + const downloadsDir = path.join(evidenceDir, "downloaded_artifacts"); + await fs.mkdir(downloadsDir, { recursive: true }); + let outPath = path.join( + downloadsDir, + `${sanitizeFilename(label)}__${suggested}` + ); + // Avoid clobbering. + for (let i = 0; i < 10; i++) { + try { + await fs.stat(outPath); + const ext = path.extname(outPath); + const base = outPath.slice(0, outPath.length - ext.length); + outPath = `${base}.${i + 1}${ext}`; + } catch { + break; + } + } + await download.saveAs(outPath); + const h = await sha256File(outPath); + await writeJson(path.join(downloadsDir, `${sanitizeFilename(label)}.json`), { + label, + suggestedFilename: suggested, + savedPath: outPath, + sha256: h + }); + return { label, filename: suggested, path: outPath, sha256: h }; +} + +main().catch(async (err) => { + const msg = String((err as any)?.message || err); + process.stderr.write(msg + "\n"); + process.exit(2); +}); diff --git a/bridge/PROMPTBUDDY.md b/bridge/PROMPTBUDDY.md new file mode 100644 index 0000000..6fbc49a --- /dev/null +++ b/bridge/PROMPTBUDDY.md @@ -0,0 +1,22 @@ +# Prompt Buddy Bridge (SSOT) + +Bridge is the single source of truth for Prompt Buddy policy, enhancement logic, and evidence. + +## Endpoints + +- `POST /soca/promptbuddy/enhance` +- `GET /soca/promptbuddy/profiles` +- `GET /soca/promptbuddy/health` +- `GET /soca/promptbuddy/capabilities` +- `GET /soca/promptbuddy/selftest` + +## SSOT Assets + +- Profiles: `core/promptbuddy/profiles/*.json` +- Evidence bundles: `runs/YYYY/MM/DD/promptbuddy//` + +## Adapters + +- OpenBrowser extension background message handlers +- CLI wrapper: `core/bin/soco-promptbuddy` +- MCP server: `core/tools/promptbuddy_mcp/server.py` diff --git a/bridge/app.py b/bridge/app.py new file mode 100644 index 0000000..546ee2e --- /dev/null +++ b/bridge/app.py @@ -0,0 +1,1983 @@ +from __future__ import annotations + +import base64 +import hashlib +import html +import io +import json +import os +import re +import sqlite3 +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, AsyncIterator, Dict, List, Optional, Sequence, Set, Tuple +from urllib.parse import urlparse +from uuid import uuid4 + +import httpx +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, Field +from starlette.responses import Response + +try: + from .promptbuddy_routes import router as promptbuddy_router + from .version import BRIDGE_VERSION as APP_VERSION +except ImportError: # pragma: no cover - script execution fallback + from promptbuddy_routes import router as promptbuddy_router # type: ignore + from version import BRIDGE_VERSION as APP_VERSION # type: ignore + + +def _utc_ts() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _sha256_text(text: str) -> str: + return _sha256_bytes(text.encode("utf-8", errors="replace")) + + +def _find_repo_root(start: Path) -> Optional[Path]: + current = start + for _ in range(12): + if ( + (current / "runs").is_dir() + and (current / "core").is_dir() + and (current / ".git").exists() + ): + return current + if current.parent == current: + return None + current = current.parent + return None + + +def _evidence_root() -> Optional[Path]: + override = os.environ.get("SOCA_OPENBROWSER_BRIDGE_EVIDENCE_ROOT", "").strip() + if override: + return Path(override).expanduser().resolve() + repo_root = _find_repo_root(Path(__file__).resolve()) + if not repo_root: + return None + return repo_root / "runs" / "_local" / "openbrowser_bridge" + + +def _openbrowser_exports_root(repo_root: Path) -> Path: + override = os.environ.get("SOCA_OPENBROWSER_EXPORTS_ROOT", "").strip() + if override: + return Path(override).expanduser().resolve() + return repo_root / "runs" / "_local" / "openbrowser_exports" + + +_SENSITIVE_HEADERS: Set[str] = { + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "x-auth-token", + "x-openai-api-key", + "x-openrouter-api-key", +} + + +def _redact_headers(headers: Dict[str, str]) -> Dict[str, str]: + out: Dict[str, str] = {} + for key, value in headers.items(): + lk = key.lower() + if lk in _SENSITIVE_HEADERS: + out[key] = "***REDACTED***" + continue + text = str(value) + if len(text) > 256: + text = text[:256] + "…" + out[key] = text + return out + + +def _require_token(authorization: Optional[str]) -> None: + expected = os.environ.get("SOCA_OPENBROWSER_BRIDGE_TOKEN", "soca").strip() + if not expected: + return + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = authorization.split(" ", 1)[1].strip() + if token != expected: + raise HTTPException(status_code=403, detail="invalid bearer token") + + +def _ollama_base_url() -> str: + base = os.environ.get("SOCA_OPENBROWSER_BRIDGE_OLLAMA_BASE_URL", "http://127.0.0.1:11434/v1").strip() + return base.rstrip("/") + + +def _openrouter_base_url() -> str: + base = ( + os.environ.get("SOCA_OPENBROWSER_BRIDGE_OPENROUTER_BASE_URL", "").strip() + or os.environ.get("OPENROUTER_BASE_URL", "").strip() + or "https://openrouter.ai/api/v1" + ) + return base.rstrip("/") + + +def _openrouter_api_key() -> str: + return ( + os.environ.get("SOCA_OPENBROWSER_BRIDGE_OPENROUTER_API_KEY", "").strip() + or os.environ.get("OPENROUTER_API_KEY", "").strip() + ) + + +def _opa_url() -> Optional[str]: + url = ( + os.environ.get("SOCA_OPENBROWSER_BRIDGE_OPA_URL", "").strip() + or os.environ.get("OPA_URL", "").strip() + ) + return url or None + + +_LANE_RANK: Dict[str, int] = { + "L0_SHADOW": 0, + "L1_ASSISTED": 1, + "L2_CONTROLLED": 2, + "L2_CONTROLLED_WRITE": 2, + "L3_AUTONOMOUS": 3, +} + + +def _lane_rank(lane: str) -> int: + return _LANE_RANK.get((lane or "").strip(), -1) + + +async def _policy_decide_chat( + *, + lane: str, + task_family: str, + requested_model: str, +) -> Dict[str, Any]: + requires_network = requested_model.startswith("openrouter/") + opa_url = _opa_url() + input_obj = { + "lane": lane, + "task_family": task_family, + "requested_model": requested_model, + "requires_network": requires_network, + } + + if opa_url: + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(opa_url, json={"input": input_obj}) + resp.raise_for_status() + data = resp.json() + decision = data.get("result", data) + return decision if isinstance(decision, dict) else {"allow": False, "reason": "invalid_opa_response"} + except Exception as e: + if requires_network: + return {"allow": False, "reason": f"opa_unavailable:{type(e).__name__}"} + + # Fallback (fail-closed for network unless lane >= L2) + if requires_network and _lane_rank(lane) < _lane_rank("L2_CONTROLLED_WRITE"): + return {"allow": False, "reason": "network_requires_L2_CONTROLLED_WRITE"} + + return { + "allow": True, + "upstream": "openrouter" if requires_network else "local", + "model": requested_model, + "provider": {"require_parameters": True, "allow_fallbacks": True, "sort": {"by": "latency", "partition": "model"}}, + "reasoning": {"effort": "high"}, + } + + +def _repo_root_or_500() -> Path: + repo_root = _find_repo_root(Path(__file__).resolve()) + if not repo_root: + raise HTTPException(status_code=500, detail="repo root not found (expected runs/, core/, .git)") + return repo_root + + +def _is_relative_to(path: Path, base: Path) -> bool: + try: + path.relative_to(base) + return True + except ValueError: + return False + + +def _resolve_under(root: Path, candidate: Path) -> Path: + root_resolved = root.expanduser().resolve() + candidate_resolved = candidate.expanduser().resolve() + if not _is_relative_to(candidate_resolved, root_resolved): + raise HTTPException(status_code=403, detail="path resolves outside allowed root") + return candidate_resolved + + +def _read_text_limited(path: Path, *, max_bytes: int = 256_000) -> str: + with path.open("rb") as f: + data = f.read(max_bytes + 1) + if len(data) > max_bytes: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace") + + +def _read_text_tail(path: Path, *, max_bytes: int = 256_000) -> str: + size = path.stat().st_size + with path.open("rb") as f: + if size > max_bytes: + f.seek(max(0, size - max_bytes)) + data = f.read(max_bytes) + return data.decode("utf-8", errors="replace") + + +_STOPWORDS: Set[str] = { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "for", + "from", + "if", + "in", + "into", + "is", + "it", + "of", + "on", + "or", + "that", + "the", + "their", + "then", + "there", + "these", + "this", + "to", + "was", + "were", + "will", + "with", + "you", + "your", +} + + +def _query_terms(query: str, *, max_terms: int = 10) -> List[str]: + tokens = [t.lower() for t in re.findall(r"[A-Za-z0-9]{3,}", query)] + terms: List[str] = [] + for tok in tokens: + if tok in _STOPWORDS: + continue + if tok not in terms: + terms.append(tok) + if len(terms) >= max_terms: + break + return terms + + +def _score_line(line_lc: str, terms: Sequence[str]) -> int: + return sum(1 for t in terms if t in line_lc) + + +def _extract_line_snippets( + text: str, + terms: Sequence[str], + *, + max_snippets: int = 6, + window_lines: int = 2, + max_chars_per_snippet: int = 1200, +) -> List[Tuple[str, int]]: + lines = text.splitlines() + if not lines: + return [] + + if not terms: + snippet = "\n".join(lines[: min(len(lines), 6)]).strip() + return [(snippet[:max_chars_per_snippet], 0)] if snippet else [] + + scored: List[Tuple[int, int]] = [] + for idx, line in enumerate(lines): + s = _score_line(line.lower(), terms) + if s: + scored.append((s, idx)) + + if not scored: + snippet = "\n".join(lines[: min(len(lines), 6)]).strip() + return [(snippet[:max_chars_per_snippet], 0)] if snippet else [] + + scored.sort(key=lambda x: (x[0], -x[1]), reverse=True) + chosen: List[Tuple[str, int]] = [] + used_indices: Set[int] = set() + for score, idx in scored: + if len(chosen) >= max_snippets: + break + if any(abs(idx - u) <= window_lines for u in used_indices): + continue + start = max(0, idx - window_lines) + end = min(len(lines), idx + window_lines + 1) + snippet = "\n".join(lines[start:end]).strip() + if not snippet: + continue + chosen.append((snippet[:max_chars_per_snippet], score)) + used_indices.add(idx) + return chosen + + +def _safe_relpath(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +class ContextPackRequest(BaseModel): + lane: str = Field(..., description="OpenBrowser lane identifier (e.g. OB_OFFLINE, OB_ONLINE_PULSE)") + query: str = Field(..., description="User query / task statement") + page_text: str = Field("", description="Visible page text or extracted content") + tab_meta: Dict[str, Any] = Field(default_factory=dict, description="Tab metadata (url/title/tabId/etc.)") + requested_layers: List[str] = Field(default_factory=list, description="Subset of 5LM layers to retrieve") + ssot_scopes: List[str] = Field(default_factory=list, description="Allowlisted SSOT scopes under core/SOCAcore") + + +class WebFetchRequest(BaseModel): + lane: str = Field("OB_OFFLINE", description="OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)") + url: str = Field(..., description="URL to fetch (http/https)") + prompt: str = Field("", description="Extraction prompt (optional)") + max_bytes: int = Field(512_000, description="Maximum bytes to fetch") + + +class PdfExtractRequest(BaseModel): + lane: str = Field("OB_OFFLINE", description="OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)") + url: str = Field(..., description="PDF URL to fetch (http/https)") + max_bytes: int = Field(15_000_000, description="Maximum bytes to fetch for the PDF") + max_pages: int = Field(50, description="Maximum number of pages to extract") + max_chars: int = Field(60_000, description="Maximum number of characters to return") + + +class Context7DocsRequest(BaseModel): + lane: str = Field("OB_OFFLINE", description="OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)") + library_id: str = Field(..., description="Context7 library id, e.g. /octokit/octokit.js") + topic: str = Field("", description="Topic focus (optional)") + max_chars: int = Field(20_000, description="Maximum characters to return") + + +class GitHubGetRequest(BaseModel): + lane: str = Field("OB_OFFLINE", description="OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)") + path: str = Field(..., description="GitHub REST path, e.g. /repos/octokit/octokit.js or /search/repositories") + query: Dict[str, Any] = Field(default_factory=dict, description="Query parameters") + max_chars: int = Field(20_000, description="Maximum characters to return") + + +class Nt2lPlanBridgeRequest(BaseModel): + prompt: str = Field(..., description="Natural-language prompt to convert into an NT2L plan") + fake_model: bool = Field(False, description="Force SOCA_FAKE_MODEL=1 for deterministic stub output") + + +class OpenBrowserPanelDumpRequest(BaseModel): + exported_utc: str = Field(..., description="UTC ISO timestamp for when the panel was exported") + source_tab_url: Optional[str] = Field(default=None, description="Active tab URL when export occurred") + title: Optional[str] = Field(default=None, description="Panel title at export time") + panel_text: str = Field(..., description="Full panel text content") + panel_html: Optional[str] = Field(default=None, description="Panel HTML snapshot (optional)") + + +class ContextSnippet(BaseModel): + layer: str + text: str + source: Dict[str, Any] + score: int = 0 + + +class ContextPackResponse(BaseModel): + snippets: List[ContextSnippet] + ssot_refs: List[Dict[str, Any]] + provenance: Dict[str, Any] + compression_summary: Dict[str, Any] + + +def _normalize_ssot_scope(scope: str) -> str: + s = scope.strip().lstrip("/") + if s in {"SOCAcore", "core/SOCAcore"}: + return "" + if s.startswith("SOCAcore/"): + return s[len("SOCAcore/") :] + if s.startswith("core/SOCAcore/"): + return s[len("core/SOCAcore/") :] + return s + + +def _collect_text_files(root: Path, *, max_files: int = 64, max_size_bytes: int = 512_000) -> List[Path]: + allowed_suffixes = {".md", ".txt", ".json", ".yaml", ".yml"} + files: List[Path] = [] + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + for name in filenames: + if len(files) >= max_files: + return files + p = Path(dirpath) / name + if p.suffix.lower() not in allowed_suffixes: + continue + try: + if p.stat().st_size > max_size_bytes: + continue + except OSError: + continue + files.append(p) + return files + + +def _ssot_snippets( + *, + repo_root: Path, + scopes: Sequence[str], + terms: Sequence[str], + max_total_snippets: int = 10, +) -> Tuple[List[ContextSnippet], List[Dict[str, Any]]]: + ssot_root = _resolve_under(repo_root, repo_root / "core" / "SOCAcore") + normalized = [_normalize_ssot_scope(s) for s in scopes if s.strip()] + if not normalized: + normalized = [""] + + candidate_files: List[Path] = [] + for scope in normalized: + target = _resolve_under(repo_root, ssot_root / scope) + if not _is_relative_to(target, ssot_root): + continue + if target.is_dir(): + candidate_files.extend(_collect_text_files(target)) + elif target.is_file(): + candidate_files.append(target) + + seen: Set[Path] = set() + files: List[Path] = [] + for f in candidate_files: + if f in seen: + continue + seen.add(f) + files.append(f) + + snippets: List[ContextSnippet] = [] + ssot_refs: List[Dict[str, Any]] = [] + for f in files: + if len(snippets) >= max_total_snippets: + break + try: + f_resolved = _resolve_under(repo_root, f) + except HTTPException: + continue + if not _is_relative_to(f_resolved, ssot_root): + continue + text = _read_text_limited(f_resolved, max_bytes=256_000) + extracted = _extract_line_snippets(text, terms, max_snippets=2) + if not extracted: + continue + sha = _sha256_file(f_resolved) + ssot_refs.append({"path": _safe_relpath(f_resolved, repo_root), "sha256": sha}) + for snippet_text, score in extracted: + if len(snippets) >= max_total_snippets: + break + snippets.append( + ContextSnippet( + layer="ssot", + text=snippet_text, + score=score, + source={ + "type": "file", + "path": _safe_relpath(f_resolved, repo_root), + "sha256": sha, + }, + ) + ) + return snippets, ssot_refs + + +def _hot_snippets(*, page_text: str, tab_meta: Dict[str, Any], terms: Sequence[str]) -> List[ContextSnippet]: + snippets: List[ContextSnippet] = [] + url = str(tab_meta.get("url") or "") + title = str(tab_meta.get("title") or "") + header_parts = [p for p in [title, url] if p] + if header_parts: + snippets.append( + ContextSnippet( + layer="hot", + text="\n".join(header_parts)[:600], + score=0, + source={"type": "tab_meta"}, + ) + ) + + extracted = _extract_line_snippets(page_text, terms, max_snippets=2) + for snippet_text, score in extracted: + snippets.append( + ContextSnippet( + layer="hot", + text=snippet_text, + score=score, + source={"type": "page_text"}, + ) + ) + return snippets + + +_PIECES_TABLES: Tuple[str, ...] = ( + "summaries_annotation_summary", + "summaries_annotation_description", + "annotations", + "conversation_messages", +) + + +def _extract_text_from_pieces_json(obj: Any) -> Optional[str]: + if not isinstance(obj, dict): + return None + if isinstance(obj.get("text"), str) and obj["text"].strip(): + return obj["text"] + os_obj = obj.get("os") + if isinstance(os_obj, dict) and isinstance(os_obj.get("text"), str) and os_obj["text"].strip(): + return os_obj["text"] + msg = obj.get("message") + if isinstance(msg, dict): + frag = msg.get("fragment") + if isinstance(frag, dict): + string = frag.get("string") + if isinstance(string, dict) and isinstance(string.get("raw"), str) and string["raw"].strip(): + return string["raw"] + return None + + +def _pieces_snippets( + *, + repo_root: Path, + terms: Sequence[str], + query: str, + max_total_snippets: int = 8, +) -> List[ContextSnippet]: + db_path = repo_root / "memory" / "pieces_library" / "pieces_client_sqlite.db" + if not db_path.exists(): + return [] + + db_path = _resolve_under(repo_root, db_path) + + patterns: List[str] = [] + if query.strip(): + patterns.append(query.strip()) + patterns.extend([t for t in terms if t not in patterns]) + patterns = patterns[:3] + + where = " OR ".join(["json LIKE ?"] * len(patterns)) if patterns else "1=0" + params: List[Any] = [f"%{p}%" for p in patterns] + + snippets: List[ContextSnippet] = [] + seen_hashes: Set[str] = set() + + con = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True, timeout=0.2) + try: + cur = con.cursor() + for table in _PIECES_TABLES: + if len(snippets) >= max_total_snippets: + break + sql = f"SELECT key, json FROM {table} WHERE {where} LIMIT ?" + cur.execute(sql, [*params, max_total_snippets * 3]) + for key, raw_json in cur.fetchall(): + if len(snippets) >= max_total_snippets: + break + if not isinstance(raw_json, str) or not raw_json.strip(): + continue + row_hash = _sha256_text(raw_json) + if row_hash in seen_hashes: + continue + seen_hashes.add(row_hash) + try: + obj = json.loads(raw_json) + except Exception: + continue + text = _extract_text_from_pieces_json(obj) + if not text: + continue + extracted = _extract_line_snippets(text, terms, max_snippets=1) + snippet_text, score = extracted[0] if extracted else (text.strip()[:1200], 0) + snippets.append( + ContextSnippet( + layer="ltm", + text=snippet_text, + score=score, + source={ + "type": "sqlite_row", + "db": _safe_relpath(db_path, repo_root), + "table": table, + "key": key, + "row_sha256": row_hash, + }, + ) + ) + finally: + con.close() + return snippets + + +_SOCA_ALIAS_MODELS: List[Dict[str, str]] = [ + {"id": "soca/auto", "name": "SOCA Auto"}, + {"id": "soca/fast", "name": "SOCA Fast"}, + {"id": "soca/best", "name": "SOCA Best"}, +] + +_FALLBACK_MODELS: List[Dict[str, str]] = [ + {"id": "qwen3-vl:2b", "name": "Qwen3-VL 2B"}, + {"id": "qwen3-vl:4b", "name": "Qwen3-VL 4B"}, + {"id": "qwen3-vl:8b", "name": "Qwen3-VL 8B"}, +] + +_UPSTREAM_MODELS_CACHE: Dict[str, Any] = {"ts": 0.0, "models": list(_FALLBACK_MODELS)} + + +def _env_nonempty(key: str) -> Optional[str]: + val = os.environ.get(key, "").strip() + return val or None + + +def _has_image_payload(body: Any) -> bool: + if not isinstance(body, dict): + return False + messages = body.get("messages") + if not isinstance(messages, list): + return False + for msg in messages: + if not isinstance(msg, dict): + continue + content = msg.get("content") + if isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + part_type = str(part.get("type") or "").lower() + if part_type in {"image", "image_url", "input_image"}: + return True + if "image_url" in part or "image" in part: + return True + elif isinstance(content, dict): + part_type = str(content.get("type") or "").lower() + if part_type in {"image", "image_url", "input_image"}: + return True + if "image_url" in content or "image" in content: + return True + return False + + +async def _fetch_upstream_models(*, ttl_seconds: float = 15.0) -> List[Dict[str, str]]: + now = time.time() + cached_ts = float(_UPSTREAM_MODELS_CACHE.get("ts") or 0.0) + cached_models = _UPSTREAM_MODELS_CACHE.get("models") + if isinstance(cached_models, list) and (now - cached_ts) < ttl_seconds: + return cached_models + + url = f"{_ollama_base_url()}/models" + try: + async with httpx.AsyncClient(timeout=3) as client: + resp = await client.get(url) + if resp.status_code >= 400: + raise RuntimeError(f"status={resp.status_code}") + payload = resp.json() + data = payload.get("data") + models: List[Dict[str, str]] = [] + if isinstance(data, list): + for item in data: + if not isinstance(item, dict): + continue + model_id = (item.get("id") or "").strip() + if not model_id: + continue + models.append({"id": model_id, "name": model_id}) + if not models: + models = list(_FALLBACK_MODELS) + _UPSTREAM_MODELS_CACHE["ts"] = now + _UPSTREAM_MODELS_CACHE["models"] = models + return models + except Exception: + return cached_models if isinstance(cached_models, list) and cached_models else list(_FALLBACK_MODELS) + + +async def _upstream_model_ids() -> Set[str]: + models = await _fetch_upstream_models() + return {m.get("id", "") for m in models if isinstance(m, dict)} + + +def _pick_first_available(candidates: Sequence[str], available: Set[str]) -> Optional[str]: + for c in candidates: + if c and c in available: + return c + for c in candidates: + if c: + return c + return None + + +async def _resolve_soca_alias_model(*, requested_model: Any, request_body: Any) -> Tuple[Any, Optional[Dict[str, Any]]]: + if not isinstance(requested_model, str): + return requested_model, None + model = requested_model.strip() + if not model.startswith("soca/"): + return requested_model, None + + variant = model.split("/", 1)[1].strip().lower() + has_image = _has_image_payload(request_body) + available = await _upstream_model_ids() + + if has_image: + env_map = { + "fast": _env_nonempty("SOCA_BRIDGE_VISION_FAST_MODEL"), + "auto": _env_nonempty("SOCA_BRIDGE_VISION_AUTO_MODEL"), + "best": _env_nonempty("SOCA_BRIDGE_VISION_BEST_MODEL"), + } + defaults = { + "fast": ["qwen3-vl:2b", "qwen3-vl:8b"], + "auto": ["qwen3-vl:8b", "qwen3-vl:2b"], + "best": ["qwen3-vl:8b", "qwen3-vl:2b"], + } + else: + env_map = { + "fast": _env_nonempty("SOCA_BRIDGE_TEXT_FAST_MODEL"), + "auto": _env_nonempty("SOCA_BRIDGE_TEXT_AUTO_MODEL"), + "best": _env_nonempty("SOCA_BRIDGE_TEXT_BEST_MODEL"), + } + defaults = { + "fast": ["qwen3:8b", "qwen2.5-coder:7b", "qwen3-vl:2b"], + "auto": ["qwen3:32b", "qwen3:8b", "qwen3-vl:8b"], + "best": ["qwen2.5-coder:32b", "qwen3:32b", "qwen3:8b", "qwen3-vl:8b"], + } + + explicit = env_map.get(variant) + candidates = ([explicit] if explicit else []) + defaults.get(variant, []) + resolved = _pick_first_available([c for c in candidates if c], available) + if not resolved: + return requested_model, {"requested": requested_model, "resolved": requested_model, "alias": variant, "has_image": has_image} + return resolved, {"requested": requested_model, "resolved": resolved, "alias": variant, "has_image": has_image} + + +class PrivateNetworkAccessMiddleware: + """ + Chrome Private Network Access (PNA) preflights require: + Access-Control-Allow-Private-Network: true + + Without this header, Chrome may block extension requests to 127.0.0.1 even + when standard CORS is configured. + """ + + def __init__(self, app: Any) -> None: + self.app = app + + async def __call__(self, scope: Dict[str, Any], receive: Any, send: Any) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + headers = {k.lower(): v for k, v in (scope.get("headers") or [])} + wants_pna = headers.get(b"access-control-request-private-network", b"").lower() == b"true" + if not wants_pna: + await self.app(scope, receive, send) + return + + async def send_wrapper(message: Dict[str, Any]) -> None: + if message.get("type") == "http.response.start": + response_headers = message.setdefault("headers", []) + if not any( + k.lower() == b"access-control-allow-private-network" for k, _v in response_headers + ): + response_headers.append((b"access-control-allow-private-network", b"true")) + await send(message) + + await self.app(scope, receive, send_wrapper) + + +app = FastAPI(title="SOCA OpenBrowser Bridge", version=APP_VERSION) + +# CORS middleware for Chrome extension support +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"], +) + +# Ensure Chrome Private Network Access (PNA) preflights succeed for localhost calls. +app.add_middleware(PrivateNetworkAccessMiddleware) +app.include_router(promptbuddy_router) + + +@app.get("/health") +async def health() -> Dict[str, Any]: + return {"status": "ok", "version": APP_VERSION} + + +def _policy_packs_path(repo_root: Path) -> Path: + return repo_root / "core" / "policy" / "openbrowser.policy_packs.json" + + +@app.get("/capabilities") +async def capabilities() -> Dict[str, Any]: + """ + Lightweight, no-auth endpoint used by local clients to discover bridge behavior. + Intended for localhost usage only (network exposure is a deployment concern). + """ + repo_root = _repo_root_or_500() + policy_path = _policy_packs_path(repo_root) + policy_sha = _sha256_file(policy_path) if policy_path.exists() else None + token_expected = os.environ.get("SOCA_OPENBROWSER_BRIDGE_TOKEN", "soca").strip() + token_required = bool(token_expected) + port = int(os.environ.get("SOCA_OPENBROWSER_BRIDGE_PORT", "9834")) + + return { + "bridge_version": APP_VERSION, + "token_required": token_required, + "port": port, + "supported_lanes": ["OB_OFFLINE", "OB_ONLINE_PULSE"], + "endpoints": [ + "/health", + "/capabilities", + "/v1/models", + "/v1/chat/completions", + "/soca/context-pack", + "/soca/policy/packs", + "/soca/webfetch", + "/soca/pdf/extract", + ], + "policy_packs": {"path": _safe_relpath(policy_path, repo_root), "sha256": policy_sha}, + } + + +@app.get("/soca/policy/packs") +async def soca_policy_packs( + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + repo_root = _repo_root_or_500() + path = _policy_packs_path(repo_root) + if not path.exists(): + raise HTTPException(status_code=404, detail="policy_packs_not_found") + raw = path.read_bytes() + sha = _sha256_bytes(raw) + try: + packs = json.loads(raw.decode("utf-8", errors="replace")) + except Exception: + raise HTTPException(status_code=500, detail="policy_packs_invalid_json") + + mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat() + return { + "ok": True, + "sha256": sha, + "effective_at": mtime, + "packs": packs, + } + + +@app.get("/v1/models") +async def list_models(authorization: Optional[str] = Header(default=None, alias="Authorization")) -> Dict[str, Any]: + _require_token(authorization) + now = int(time.time()) + upstream = await _fetch_upstream_models() + merged: List[Dict[str, str]] = [] + seen: Set[str] = set() + for entry in [*_SOCA_ALIAS_MODELS, *upstream]: + model_id = (entry.get("id") or "").strip() + if not model_id or model_id in seen: + continue + seen.add(model_id) + merged.append({"id": model_id}) + return { + "object": "list", + "data": [ + { + "id": model["id"], + "object": "model", + "created": now, + "owned_by": "soca-openbrowser-bridge", + } + for model in merged + ], + } + + +def _model_dump(model: BaseModel) -> Dict[str, Any]: + if hasattr(model, "model_dump"): + # Pydantic v2 + return model.model_dump(exclude_none=True) + # Pydantic v1 + return model.dict(exclude_none=True) + + +def _file_source_provenance( + path: Path, + *, + repo_root: Path, + hash_limit_bytes: int = 1_000_000, + sample_tail_bytes: int = 256_000, +) -> Dict[str, Any]: + size = path.stat().st_size + rel = _safe_relpath(path, repo_root) + if size <= hash_limit_bytes: + return {"type": "file", "path": rel, "sha256": _sha256_file(path), "bytes": size} + + with path.open("rb") as f: + if size > sample_tail_bytes: + f.seek(max(0, size - sample_tail_bytes)) + sample = f.read(sample_tail_bytes) + + return { + "type": "file_sample", + "path": rel, + "bytes": size, + "sample_bytes": len(sample), + "sample_sha256": _sha256_bytes(sample), + "sample_strategy": "tail", + } + + +def _warm_snippets(*, repo_root: Path, terms: Sequence[str]) -> List[ContextSnippet]: + sources = [ + repo_root / "logs" / "soca" / "launch_metrics_latest.json", + repo_root / "logs" / "soca" / "soca_health_summary_latest.json", + repo_root / "logs" / "soca" / "memory_continuity_latest.json", + ] + snippets: List[ContextSnippet] = [] + for p in sources: + if not p.exists() or not p.is_file(): + continue + text = _read_text_limited(p, max_bytes=128_000) + extracted = _extract_line_snippets(text, terms, max_snippets=1) + if not extracted: + continue + snippet_text, score = extracted[0] + snippets.append( + ContextSnippet( + layer="warm", + text=snippet_text, + score=score, + source=_file_source_provenance(p, repo_root=repo_root), + ) + ) + return snippets + + +def _cold_snippets(*, repo_root: Path, terms: Sequence[str]) -> List[ContextSnippet]: + sources = [ + repo_root / "logs" / "soca" / "AUDIT_REPORT.md", + repo_root / "logs" / "soca" / "CRITICAL_IMPROVEMENTS_REQUIRED.md", + repo_root / "logs" / "soca" / "mcp_guardian.log", + ] + snippets: List[ContextSnippet] = [] + for p in sources: + if not p.exists() or not p.is_file(): + continue + text = _read_text_tail(p, max_bytes=192_000) if p.suffix.lower() == ".log" else _read_text_limited(p, max_bytes=192_000) + extracted = _extract_line_snippets(text, terms, max_snippets=1) + if not extracted: + continue + snippet_text, score = extracted[0] + snippets.append( + ContextSnippet( + layer="cold", + text=snippet_text, + score=score, + source=_file_source_provenance(p, repo_root=repo_root), + ) + ) + return snippets + + +_LOCAL_HOSTS: Set[str] = {"127.0.0.1", "localhost", "::1"} + +_ALLOWLIST_HEADER = "x-soca-allowlist" +_ALLOWLIST_ENV_VAR = "SOCA_OPENBROWSER_BRIDGE_ALLOWLIST_DOMAINS" + + +def _parse_allowlist_text(text: str) -> Set[str]: + """ + Accept newline and comma separated domain entries. + + Supported forms: + - example.com + - api.github.com + - https://api.github.com/ + - *.example.com (treated as example.com suffix match) + - host:port (port ignored) + + Returned entries are lowercased hostnames without trailing dots. + """ + items: Set[str] = set() + if not text: + return items + + for raw in re.split(r"[,\n]", text): + entry = (raw or "").strip() + if not entry or entry.startswith("#"): + continue + + entry = entry.replace("http://", "").replace("https://", "") + entry = entry.split("/", 1)[0].strip() + if not entry: + continue + + if entry.startswith("*."): + entry = entry[2:] + + # Strip port if present (and not an IPv6 literal). + if entry.startswith("[") and "]" in entry: + host = entry[1 : entry.index("]")] + else: + host = entry.split(":", 1)[0] + + host = host.strip().lower().rstrip(".") + if host: + items.add(host) + + return items + + +def _effective_allowlist_domains(request: Request) -> Set[str]: + """ + SSOT policy: if the bridge host sets an env allowlist, it is authoritative. + Otherwise, accept allowlist from the client request header. + """ + env_text = os.environ.get(_ALLOWLIST_ENV_VAR, "").strip() + if env_text: + return _parse_allowlist_text(env_text) + header_text = request.headers.get(_ALLOWLIST_HEADER, "") or "" + return _parse_allowlist_text(header_text) + + +def _host_is_allowlisted(host: str, allowlist: Set[str]) -> bool: + host = (host or "").strip().lower().rstrip(".") + if not host: + return False + if host in allowlist: + return True + for domain in allowlist: + if domain and host.endswith("." + domain): + return True + return False + + +def _normalize_lane(lane: str) -> str: + return (lane or "").strip() + + +def _lane_requires_network(lane: str) -> bool: + return _normalize_lane(lane) == "OB_ONLINE_PULSE" + + +def _require_online_lane(lane: str) -> None: + if not _lane_requires_network(lane): + raise HTTPException(status_code=403, detail="lane_requires_network: switch to OB_ONLINE_PULSE") + + +def _require_url_allowed_for_lane( + lane: str, + url: str, + *, + allowlist_domains: Optional[Set[str]] = None, +) -> None: + try: + parsed = urlparse(url) + except Exception: + raise HTTPException(status_code=400, detail="invalid_url") + + if parsed.scheme not in {"http", "https"}: + raise HTTPException(status_code=400, detail="unsupported_url_scheme") + + host = (parsed.hostname or "").strip().lower() + if not host: + raise HTTPException(status_code=400, detail="invalid_url_host") + + if not _lane_requires_network(lane): + if host not in _LOCAL_HOSTS: + raise HTTPException(status_code=403, detail=f"lane_offline_blocks_host:{host}") + return + + allowlist = allowlist_domains or set() + if not allowlist: + raise HTTPException(status_code=403, detail="lane_online_missing_allowlist") + if not _host_is_allowlisted(host, allowlist): + raise HTTPException(status_code=403, detail=f"lane_online_blocks_host:{host}") + + +def _start_evidence_dir(*, evidence_root: Optional[Path], prefix: str) -> Tuple[Optional[Path], Optional[str]]: + if not evidence_root: + return None, None + run_id = f"{_utc_ts()}-{uuid4().hex[:8]}-{prefix}" + evidence_dir = (evidence_root / run_id).resolve() + evidence_dir.mkdir(parents=True, exist_ok=True) + return evidence_dir, run_id + + +_HTML_TAG_RE = re.compile(r"(?s)<[^>]+>") +_HTML_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?") + + +def _html_to_text(html_text: str) -> str: + cleaned = _HTML_SCRIPT_STYLE_RE.sub(" ", html_text) + cleaned = _HTML_TAG_RE.sub(" ", cleaned) + cleaned = html.unescape(cleaned) + cleaned = re.sub(r"[ \t]+", " ", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + + +async def _fetch_url_text(*, url: str, max_bytes: int) -> Tuple[str, bool, Optional[str], int]: + headers = {"user-agent": f"soca-openbrowser-bridge/{APP_VERSION}"} + truncated = False + chunks: List[bytes] = [] + captured = 0 + + async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client: + async with client.stream("GET", url, headers=headers) as resp: + content_type = resp.headers.get("content-type") + status_code = resp.status_code + async for chunk in resp.aiter_bytes(): + if not chunk: + continue + if captured + len(chunk) > max_bytes: + chunk = chunk[: max(0, max_bytes - captured)] + truncated = True + chunks.append(chunk) + captured += len(chunk) + if truncated: + break + + data = b"".join(chunks) + text = data.decode("utf-8", errors="replace") + if content_type and "text/html" in content_type.lower(): + text = _html_to_text(text) + return text, truncated, content_type, status_code + + +def _ensure_core_on_syspath(repo_root: Path) -> None: + core_dir = (repo_root / "core").resolve() + core_str = str(core_dir) + if core_str not in sys.path: + sys.path.insert(0, core_str) + + +def _resolve_1password_ref(value: str) -> str: + if not value.startswith("op://"): + return value + try: + out = subprocess.check_output(["op", "read", value], text=True).strip() + except FileNotFoundError as e: + raise HTTPException(status_code=500, detail="secret_unavailable: op_cli_missing") from e + except subprocess.CalledProcessError as e: + raise HTTPException(status_code=500, detail="secret_unavailable: op_read_failed") from e + if not out: + raise HTTPException(status_code=500, detail="secret_unavailable: empty_secret") + return out + + +def _github_token_or_500() -> str: + for key in ("GITHUB_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"): + raw = os.environ.get(key, "").strip() + if raw: + return _resolve_1password_ref(raw) + raise HTTPException(status_code=500, detail="missing_env: GITHUB_TOKEN") + + +@app.post("/soca/context-pack", response_model=ContextPackResponse) +async def context_pack( + payload: ContextPackRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> ContextPackResponse: + _require_token(authorization) + + repo_root = _repo_root_or_500() + terms = _query_terms(payload.query) + + requested_layers = [s.strip().lower() for s in payload.requested_layers if s and s.strip()] + if not requested_layers: + requested_layers = ["hot", "warm", "ltm"] + + allowed_layers = {"hot", "warm", "cold", "vector", "ltm"} + unknown = [l for l in requested_layers if l not in allowed_layers] + if unknown: + raise HTTPException(status_code=400, detail=f"unknown requested_layers: {unknown}") + + ssot_scopes = payload.ssot_scopes or ["SOCAcore"] + for scope in ssot_scopes: + norm = _normalize_ssot_scope(scope) + parts = Path(norm).parts if norm else () + if any(p == ".." for p in parts): + raise HTTPException(status_code=400, detail="invalid ssot_scope") + + snippets: List[ContextSnippet] = [] + ssot_refs: List[Dict[str, Any]] = [] + + if "hot" in requested_layers: + snippets.extend(_hot_snippets(page_text=payload.page_text, tab_meta=payload.tab_meta, terms=terms)) + + ssot_snips, ssot_refs = _ssot_snippets(repo_root=repo_root, scopes=ssot_scopes, terms=terms) + snippets.extend(ssot_snips) + + if "warm" in requested_layers: + snippets.extend(_warm_snippets(repo_root=repo_root, terms=terms)) + + if "cold" in requested_layers: + snippets.extend(_cold_snippets(repo_root=repo_root, terms=terms)) + + if "ltm" in requested_layers: + snippets.extend(_pieces_snippets(repo_root=repo_root, terms=terms, query=payload.query)) + + layer_status: Dict[str, str] = {"vector": "unavailable"} if "vector" in requested_layers else {} + + evidence_root = _evidence_root() + run_id: Optional[str] = None + evidence_dir: Optional[Path] = None + if evidence_root: + run_id = f"{_utc_ts()}-{uuid4().hex[:8]}-context-pack" + evidence_dir = (evidence_root / run_id).resolve() + evidence_dir.mkdir(parents=True, exist_ok=True) + + provenance: Dict[str, Any] = { + "generated_utc": datetime.now(timezone.utc).isoformat(), + "bridge_version": APP_VERSION, + "lane": payload.lane, + "retrieval_mode": "local-only", + "requested_layers": requested_layers, + "ssot_scopes": ssot_scopes, + "query_terms": terms, + "layer_status": layer_status, + "run_id": run_id, + } + + compression_summary: Dict[str, Any] = { + "input_chars": { + "query": len(payload.query), + "page_text": len(payload.page_text), + "tab_meta": len(json.dumps(payload.tab_meta, ensure_ascii=False)), + }, + "output_chars": { + "snippets": sum(len(s.text) for s in snippets), + }, + "limits": { + "ssot_max_total_snippets": 10, + "page_text_snippets": 2, + "pieces_max_total_snippets": 8, + }, + } + + response = ContextPackResponse( + snippets=snippets, + ssot_refs=ssot_refs, + provenance=provenance, + compression_summary=compression_summary, + ) + + if evidence_dir: + (evidence_dir / "request.json").write_text( + json.dumps(_model_dump(payload), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0] if run_id else None, + "client_headers": _redact_headers(dict(request.headers)), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + (evidence_dir / "context_pack.json").write_text( + json.dumps(_model_dump(response), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return response + + +@app.post("/soca/openbrowser/panel-dump") +async def soca_openbrowser_panel_dump( + payload: OpenBrowserPanelDumpRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + + repo_root = _repo_root_or_500() + exports_root = _openbrowser_exports_root(repo_root) + exports_root.mkdir(parents=True, exist_ok=True) + + run_id = f"{_utc_ts()}-{uuid4().hex[:8]}-panel-dump" + evidence_dir = (exports_root / run_id).resolve() + evidence_dir.mkdir(parents=True, exist_ok=True) + + panel_text = payload.panel_text or "" + (evidence_dir / "panel.txt").write_text(panel_text, encoding="utf-8") + + if payload.panel_html and payload.panel_html.strip(): + (evidence_dir / "panel.html").write_text(payload.panel_html, encoding="utf-8") + + meta = { + "run_id": run_id, + "exported_utc": payload.exported_utc, + "received_utc": datetime.now(timezone.utc).isoformat(), + "source_tab_url": payload.source_tab_url, + "title": payload.title, + "panel_text_bytes": len(panel_text.encode("utf-8", errors="replace")), + "panel_html_bytes": len(payload.panel_html.encode("utf-8", errors="replace")) + if payload.panel_html + else 0, + "bridge_version": APP_VERSION, + "client_headers": _redact_headers(dict(request.headers)), + } + (evidence_dir / "meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + rel_paths = [_safe_relpath(p, repo_root) for p in sorted(files)] + return {"run_id": run_id, "paths": rel_paths} + + +@app.post("/soca/webfetch") +async def soca_webfetch( + payload: WebFetchRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + + lane = _normalize_lane(payload.lane) or "OB_OFFLINE" + _require_url_allowed_for_lane( + lane, + payload.url, + allowlist_domains=_effective_allowlist_domains(request), + ) + + evidence_dir, run_id = _start_evidence_dir(evidence_root=_evidence_root(), prefix="webfetch") + if evidence_dir: + (evidence_dir / "request.json").write_text( + json.dumps(_model_dump(payload), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0] if run_id else None, + "client_headers": _redact_headers(dict(request.headers)), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + text, truncated, content_type, status_code = await _fetch_url_text(url=payload.url, max_bytes=payload.max_bytes) + if status_code >= 400: + raise HTTPException(status_code=502, detail=f"webfetch_upstream_error: status={status_code}") + + terms = _query_terms(payload.prompt) + snippets = _extract_line_snippets(text, terms, max_snippets=6) + excerpt = "\n\n---\n\n".join(s for s, _score in snippets).strip() if snippets else text[:12000].strip() + + response = { + "ok": True, + "lane": lane, + "url": payload.url, + "status_code": status_code, + "content_type": content_type, + "truncated": truncated or len(excerpt) < len(text), + "text": excerpt[:20000], + } + + if evidence_dir: + (evidence_dir / "response.json").write_text( + json.dumps(response, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return response + + +async def _fetch_url_bytes(*, url: str, max_bytes: int) -> Tuple[bytes, bool, Optional[str], int]: + headers = {"user-agent": f"soca-openbrowser-bridge/{APP_VERSION}"} + truncated = False + chunks: List[bytes] = [] + captured = 0 + + async with httpx.AsyncClient(follow_redirects=True, timeout=45) as client: + async with client.stream("GET", url, headers=headers) as resp: + status_code = resp.status_code + content_type = resp.headers.get("content-type") + if status_code >= 400: + # Preserve status_code for callers. + return b"", False, content_type, status_code + + async for chunk in resp.aiter_bytes(): + if not chunk: + continue + remaining = max_bytes - captured + if remaining <= 0: + truncated = True + break + if len(chunk) > remaining: + chunks.append(chunk[:remaining]) + captured += remaining + truncated = True + break + chunks.append(chunk) + captured += len(chunk) + + return b"".join(chunks), truncated, content_type, 200 + + +@app.post("/soca/pdf/extract") +async def soca_pdf_extract( + payload: PdfExtractRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + + lane = _normalize_lane(payload.lane) or "OB_OFFLINE" + _require_url_allowed_for_lane( + lane, + payload.url, + allowlist_domains=_effective_allowlist_domains(request), + ) + + evidence_dir, run_id = _start_evidence_dir(evidence_root=_evidence_root(), prefix="pdf-extract") + if evidence_dir: + (evidence_dir / "request.json").write_text( + json.dumps(_model_dump(payload), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0] if run_id else None, + "client_headers": _redact_headers(dict(request.headers)), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + pdf_bytes, truncated_bytes, content_type, status_code = await _fetch_url_bytes( + url=payload.url, max_bytes=int(payload.max_bytes or 0) or 15_000_000 + ) + if status_code >= 400: + raise HTTPException(status_code=502, detail=f"pdf_fetch_upstream_error: status={status_code}") + if not pdf_bytes: + raise HTTPException(status_code=502, detail="pdf_fetch_empty") + + try: + from pypdf import PdfReader # type: ignore + except Exception as e: + raise HTTPException(status_code=500, detail=f"missing_dep:pypdf:{type(e).__name__}") + + sha = _sha256_bytes(pdf_bytes) + pages_total: Optional[int] = None + pages_extracted = 0 + text_parts: List[str] = [] + out_chars = 0 + truncated_text = False + + try: + reader = PdfReader(io.BytesIO(pdf_bytes)) + pages_total = len(reader.pages) + max_pages = max(1, int(payload.max_pages or 0) or 50) + max_chars = max(1000, int(payload.max_chars or 0) or 60_000) + + for i, page in enumerate(reader.pages): + if i >= max_pages: + truncated_text = True + break + t = page.extract_text() or "" + if not t: + continue + remaining = max_chars - out_chars + if remaining <= 0: + truncated_text = True + break + if len(t) > remaining: + text_parts.append(t[:remaining]) + out_chars += remaining + truncated_text = True + pages_extracted = i + 1 + break + text_parts.append(t) + out_chars += len(t) + pages_extracted = i + 1 + except Exception as e: + raise HTTPException(status_code=400, detail=f"pdf_parse_failed:{type(e).__name__}") + + text = "\n\n".join(text_parts).strip() + response = { + "ok": True, + "lane": lane, + "url": payload.url, + "content_type": content_type, + "sha256": sha, + "pages_total": pages_total, + "pages_extracted": pages_extracted, + "truncated": bool(truncated_bytes or truncated_text), + "text": text, + } + + if evidence_dir: + (evidence_dir / "response.json").write_text( + json.dumps(response, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return response + + +@app.post("/soca/context7/get-library-docs") +async def soca_context7_get_library_docs( + payload: Context7DocsRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + lane = _normalize_lane(payload.lane) or "OB_OFFLINE" + _require_online_lane(lane) + + library = payload.library_id.strip().lstrip("/") + if not library: + raise HTTPException(status_code=400, detail="invalid_library_id") + + url = f"https://context7.com/{library}/llms.txt" + _require_url_allowed_for_lane( + lane, + url, + allowlist_domains=_effective_allowlist_domains(request), + ) + + evidence_dir, run_id = _start_evidence_dir(evidence_root=_evidence_root(), prefix="context7") + if evidence_dir: + (evidence_dir / "request.json").write_text( + json.dumps(_model_dump(payload), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0] if run_id else None, + "client_headers": _redact_headers(dict(request.headers)), + "resolved_url": url, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + text, truncated, content_type, status_code = await _fetch_url_text(url=url, max_bytes=512_000) + if status_code >= 400: + raise HTTPException(status_code=502, detail=f"context7_upstream_error: status={status_code}") + + terms = _query_terms(payload.topic or "") + snippets = _extract_line_snippets(text, terms, max_snippets=8) + excerpt = "\n\n---\n\n".join(s for s, _score in snippets).strip() if snippets else text[: payload.max_chars].strip() + + response = { + "ok": True, + "lane": lane, + "library_id": payload.library_id, + "topic": payload.topic, + "url": url, + "status_code": status_code, + "content_type": content_type, + "truncated": truncated or len(excerpt) < len(text), + "text": excerpt[: payload.max_chars], + } + + if evidence_dir: + (evidence_dir / "response.json").write_text( + json.dumps(response, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return response + + +@app.post("/soca/github/get") +async def soca_github_get( + payload: GitHubGetRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + lane = _normalize_lane(payload.lane) or "OB_OFFLINE" + _require_online_lane(lane) + + path = payload.path.strip() + if not path.startswith("/") or "://" in path: + raise HTTPException(status_code=400, detail="invalid_github_path") + + url = f"https://api.github.com{path}" + _require_url_allowed_for_lane( + lane, + url, + allowlist_domains=_effective_allowlist_domains(request), + ) + token = _github_token_or_500() + + evidence_dir, run_id = _start_evidence_dir(evidence_root=_evidence_root(), prefix="github") + if evidence_dir: + (evidence_dir / "request.json").write_text( + json.dumps(_model_dump(payload), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0] if run_id else None, + "client_headers": _redact_headers(dict(request.headers)), + "resolved_url": url, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + headers = { + "authorization": f"Bearer {token}", + "accept": "application/vnd.github+json", + "user-agent": f"soca-openbrowser-bridge/{APP_VERSION}", + "x-github-api-version": "2022-11-28", + } + + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get(url, headers=headers, params=payload.query) + + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail=f"github_upstream_error: status={resp.status_code} body={resp.text[:400]}") + + content_type = resp.headers.get("content-type") + decoded_text: Optional[str] = None + + data: Any + try: + data = resp.json() + if isinstance(data, dict) and data.get("encoding") == "base64" and isinstance(data.get("content"), str): + try: + decoded_bytes = base64.b64decode(data["content"], validate=False) + decoded_text = decoded_bytes.decode("utf-8", errors="replace") + except Exception: + decoded_text = None + except Exception: + data = resp.text + + response = { + "ok": True, + "lane": lane, + "path": path, + "url": url, + "status_code": resp.status_code, + "content_type": content_type, + "data": data, + "decoded_text": decoded_text[: payload.max_chars] if isinstance(decoded_text, str) else None, + } + + if evidence_dir: + (evidence_dir / "response.json").write_text( + json.dumps(response, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return response + + +@app.post("/soca/nt2l/plan") +async def soca_nt2l_plan( + payload: Nt2lPlanBridgeRequest, + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Dict[str, Any]: + _require_token(authorization) + repo_root = _repo_root_or_500() + _ensure_core_on_syspath(repo_root) + + evidence_dir, run_id = _start_evidence_dir(evidence_root=_evidence_root(), prefix="nt2l_plan") + if evidence_dir: + (evidence_dir / "request.json").write_text( + json.dumps(_model_dump(payload), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0] if run_id else None, + "client_headers": _redact_headers(dict(request.headers)), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + prev_fake = os.getenv("SOCA_FAKE_MODEL") + if payload.fake_model: + os.environ["SOCA_FAKE_MODEL"] = "1" + try: + import nt2l_prompt_to_plan + + plan = nt2l_prompt_to_plan.prompt_to_nt2l_plan(payload.prompt) + response = plan.model_dump(mode="json") + finally: + if payload.fake_model: + if prev_fake is None: + os.environ.pop("SOCA_FAKE_MODEL", None) + else: + os.environ["SOCA_FAKE_MODEL"] = prev_fake + + if evidence_dir: + (evidence_dir / "response.json").write_text( + json.dumps(response, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return response + + +async def _proxy_stream_to_upstream( + *, + upstream_url: str, + request_headers: Dict[str, str], + request_body: Any, + evidence_dir: Optional[Path], + capture_limit_bytes: int = 256_000, +) -> StreamingResponse: + client = httpx.AsyncClient(timeout=None) + cm = client.stream( + "POST", + upstream_url, + headers=request_headers, + json=request_body, + ) + + upstream = await cm.__aenter__() + captured = 0 + capture_path: Optional[Path] = None + if evidence_dir: + capture_path = evidence_dir / "response_sample.bin" + capture_path.parent.mkdir(parents=True, exist_ok=True) + + async def iterator() -> AsyncIterator[bytes]: + nonlocal captured + try: + if capture_path: + with capture_path.open("wb") as f: + async for chunk in upstream.aiter_raw(): + if captured < capture_limit_bytes: + part = chunk[: max(0, capture_limit_bytes - captured)] + f.write(part) + captured += len(part) + yield chunk + else: + async for chunk in upstream.aiter_raw(): + yield chunk + finally: + await cm.__aexit__(None, None, None) + await client.aclose() + if evidence_dir: + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + headers = {} + content_type = upstream.headers.get("content-type") + if content_type: + headers["content-type"] = content_type + return StreamingResponse( + iterator(), + status_code=upstream.status_code, + headers=headers, + ) + + +@app.post("/v1/chat/completions", response_model=None) +async def chat_completions( + request: Request, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> Response: + _require_token(authorization) + + body = await request.json() + requested_model = body.get("model") if isinstance(body, dict) else None + resolved_model, alias_meta = await _resolve_soca_alias_model( + requested_model=requested_model, + request_body=body, + ) + effective_model = resolved_model if isinstance(resolved_model, str) else (requested_model or "") + + lane = request.headers.get("x-soca-lane") or os.environ.get("SOCA_LANE") or "L1_ASSISTED" + task_family = request.headers.get("x-soca-task-family") or "TRIAGE" + decision = await _policy_decide_chat( + lane=lane, + task_family=task_family, + requested_model=str(effective_model), + ) + if not decision.get("allow"): + raise HTTPException(status_code=403, detail=str(decision.get("reason") or "blocked")) + + policy_model = str(decision.get("model") or effective_model) + upstream_kind = str(decision.get("upstream") or ("openrouter" if policy_model.startswith("openrouter/") else "local")) + upstream_url = ( + f"{_openrouter_base_url()}/chat/completions" + if upstream_kind == "openrouter" + else f"{_ollama_base_url()}/chat/completions" + ) + + upstream_body = body if isinstance(body, dict) else {} + if isinstance(body, dict): + upstream_body = dict(body) + upstream_model = policy_model + if upstream_kind == "openrouter" and upstream_model.startswith("openrouter/"): + upstream_model = upstream_model.replace("openrouter/", "", 1) + upstream_body["model"] = upstream_model + + if upstream_kind == "openrouter": + provider = decision.get("provider") + if provider and "provider" not in upstream_body: + upstream_body["provider"] = provider + reasoning = decision.get("reasoning") + if reasoning and "reasoning" not in upstream_body: + upstream_body["reasoning"] = reasoning + + evidence_root = _evidence_root() + evidence_dir: Optional[Path] = None + if evidence_root: + run_id = f"{_utc_ts()}-{uuid4().hex[:8]}" + evidence_dir = (evidence_root / run_id).resolve() + evidence_dir.mkdir(parents=True, exist_ok=True) + (evidence_dir / "request.json").write_text( + json.dumps(body, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (evidence_dir / "meta.json").write_text( + json.dumps( + { + "timestamp": run_id.split("-", 1)[0], + "upstream_url": upstream_url, + "policy": {"lane": lane, "task_family": task_family, "decision": decision}, + "model": { + "requested": requested_model, + "resolved": resolved_model, + "resolution": alias_meta, + }, + "client_headers": _redact_headers(dict(request.headers)), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + upstream_headers = {"content-type": "application/json"} + if upstream_kind == "openrouter": + api_key = _openrouter_api_key() + if not api_key: + raise HTTPException(status_code=500, detail="OPENROUTER_API_KEY missing") + upstream_headers["Authorization"] = f"Bearer {api_key}" + http_referer = os.environ.get("OPENROUTER_HTTP_REFERER", "").strip() + if http_referer: + upstream_headers["HTTP-Referer"] = http_referer + x_title = os.environ.get("OPENROUTER_X_TITLE", "").strip() + if x_title: + upstream_headers["X-Title"] = x_title + + stream = bool(body.get("stream")) + if stream: + response = await _proxy_stream_to_upstream( + upstream_url=upstream_url, + request_headers=upstream_headers, + request_body=upstream_body, + evidence_dir=evidence_dir, + ) + return response + + timeout = 180 if upstream_kind == "openrouter" else 90 + async with httpx.AsyncClient(timeout=timeout) as client: + upstream = await client.post(upstream_url, headers=upstream_headers, json=upstream_body) + + if evidence_dir: + (evidence_dir / "response.json").write_text( + json.dumps(upstream.json(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + files = [p for p in evidence_dir.glob("*") if p.is_file()] + (evidence_dir / "sha256.txt").write_text( + "\n".join(f"{_sha256_file(p)} {p.name}" for p in sorted(files)), + encoding="utf-8", + ) + + return JSONResponse(content=upstream.json(), status_code=upstream.status_code) + + +if __name__ == "__main__": + import uvicorn + + host = os.environ.get("SOCA_OPENBROWSER_BRIDGE_HOST", "127.0.0.1").strip() or "127.0.0.1" + port = int(os.environ.get("SOCA_OPENBROWSER_BRIDGE_PORT", "9834")) + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/bridge/openapi_snapshot.json b/bridge/openapi_snapshot.json new file mode 100644 index 0000000..5f0d84a --- /dev/null +++ b/bridge/openapi_snapshot.json @@ -0,0 +1,1430 @@ +{ + "components": { + "schemas": { + "Constraints": { + "additionalProperties": false, + "properties": { + "allow_online_enrichment": { + "default": false, + "title": "Allow Online Enrichment", + "type": "boolean" + }, + "keep_language": { + "default": true, + "title": "Keep Language", + "type": "boolean" + }, + "max_chars": { + "anyOf": [ + { + "maximum": 200000.0, + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Chars" + }, + "preserve_code_blocks": { + "default": true, + "title": "Preserve Code Blocks", + "type": "boolean" + } + }, + "title": "Constraints", + "type": "object" + }, + "Context7DocsRequest": { + "properties": { + "lane": { + "default": "OB_OFFLINE", + "description": "OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)", + "title": "Lane", + "type": "string" + }, + "library_id": { + "description": "Context7 library id, e.g. /octokit/octokit.js", + "title": "Library Id", + "type": "string" + }, + "max_chars": { + "default": 20000, + "description": "Maximum characters to return", + "title": "Max Chars", + "type": "integer" + }, + "topic": { + "default": "", + "description": "Topic focus (optional)", + "title": "Topic", + "type": "string" + } + }, + "required": ["library_id"], + "title": "Context7DocsRequest", + "type": "object" + }, + "ContextPackRequest": { + "properties": { + "lane": { + "description": "OpenBrowser lane identifier (e.g. OB_OFFLINE, OB_ONLINE_PULSE)", + "title": "Lane", + "type": "string" + }, + "page_text": { + "default": "", + "description": "Visible page text or extracted content", + "title": "Page Text", + "type": "string" + }, + "query": { + "description": "User query / task statement", + "title": "Query", + "type": "string" + }, + "requested_layers": { + "description": "Subset of 5LM layers to retrieve", + "items": { + "type": "string" + }, + "title": "Requested Layers", + "type": "array" + }, + "ssot_scopes": { + "description": "Allowlisted SSOT scopes under core/SOCAcore", + "items": { + "type": "string" + }, + "title": "Ssot Scopes", + "type": "array" + }, + "tab_meta": { + "additionalProperties": true, + "description": "Tab metadata (url/title/tabId/etc.)", + "title": "Tab Meta", + "type": "object" + } + }, + "required": ["lane", "query"], + "title": "ContextPackRequest", + "type": "object" + }, + "ContextPackResponse": { + "properties": { + "compression_summary": { + "additionalProperties": true, + "title": "Compression Summary", + "type": "object" + }, + "provenance": { + "additionalProperties": true, + "title": "Provenance", + "type": "object" + }, + "snippets": { + "items": { + "$ref": "#/components/schemas/ContextSnippet" + }, + "title": "Snippets", + "type": "array" + }, + "ssot_refs": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Ssot Refs", + "type": "array" + } + }, + "required": [ + "snippets", + "ssot_refs", + "provenance", + "compression_summary" + ], + "title": "ContextPackResponse", + "type": "object" + }, + "ContextSnippet": { + "properties": { + "layer": { + "title": "Layer", + "type": "string" + }, + "score": { + "default": 0, + "title": "Score", + "type": "integer" + }, + "source": { + "additionalProperties": true, + "title": "Source", + "type": "object" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": ["layer", "text", "source"], + "title": "ContextSnippet", + "type": "object" + }, + "DiffInfo": { + "additionalProperties": false, + "properties": { + "data": { + "title": "Data" + }, + "type": { + "default": "unified", + "enum": ["spans", "unified"], + "title": "Type", + "type": "string" + } + }, + "required": ["data"], + "title": "DiffInfo", + "type": "object" + }, + "ErrorItem": { + "additionalProperties": false, + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": ["code", "message"], + "title": "ErrorItem", + "type": "object" + }, + "GitHubGetRequest": { + "properties": { + "lane": { + "default": "OB_OFFLINE", + "description": "OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)", + "title": "Lane", + "type": "string" + }, + "max_chars": { + "default": 20000, + "description": "Maximum characters to return", + "title": "Max Chars", + "type": "integer" + }, + "path": { + "description": "GitHub REST path, e.g. /repos/octokit/octokit.js or /search/repositories", + "title": "Path", + "type": "string" + }, + "query": { + "additionalProperties": true, + "description": "Query parameters", + "title": "Query", + "type": "object" + } + }, + "required": ["path"], + "title": "GitHubGetRequest", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "Lane": { + "enum": ["OB_OFFLINE", "OB_ONLINE_PULSE"], + "title": "Lane", + "type": "string" + }, + "Mode": { + "enum": ["clarify", "structure", "compress", "persona", "safe_exec"], + "title": "Mode", + "type": "string" + }, + "MutationInfo": { + "additionalProperties": false, + "properties": { + "note": { + "title": "Note", + "type": "string" + }, + "type": { + "enum": [ + "reorder", + "clarify", + "add_constraints", + "compress", + "persona", + "safety", + "profile" + ], + "title": "Type", + "type": "string" + } + }, + "required": ["type", "note"], + "title": "MutationInfo", + "type": "object" + }, + "Nt2lPlanBridgeRequest": { + "properties": { + "fake_model": { + "default": false, + "description": "Force SOCA_FAKE_MODEL=1 for deterministic stub output", + "title": "Fake Model", + "type": "boolean" + }, + "prompt": { + "description": "Natural-language prompt to convert into an NT2L plan", + "title": "Prompt", + "type": "string" + } + }, + "required": ["prompt"], + "title": "Nt2lPlanBridgeRequest", + "type": "object" + }, + "OpenBrowserPanelDumpRequest": { + "properties": { + "exported_utc": { + "description": "UTC ISO timestamp for when the panel was exported", + "title": "Exported Utc", + "type": "string" + }, + "panel_html": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Panel HTML snapshot (optional)", + "title": "Panel Html" + }, + "panel_text": { + "description": "Full panel text content", + "title": "Panel Text", + "type": "string" + }, + "source_tab_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Active tab URL when export occurred", + "title": "Source Tab Url" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Panel title at export time", + "title": "Title" + } + }, + "required": ["exported_utc", "panel_text"], + "title": "OpenBrowserPanelDumpRequest", + "type": "object" + }, + "PolicyInfo": { + "additionalProperties": false, + "properties": { + "lane_allowed": { + "title": "Lane Allowed", + "type": "boolean" + }, + "model": { + "title": "Model", + "type": "string" + }, + "network_used": { + "title": "Network Used", + "type": "boolean" + } + }, + "required": ["lane_allowed", "network_used", "model"], + "title": "PolicyInfo", + "type": "object" + }, + "PromptContext": { + "additionalProperties": false, + "properties": { + "intent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Intent" + }, + "tab_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tab Title" + }, + "tab_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tab Url" + }, + "target_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Base Url" + } + }, + "title": "PromptContext", + "type": "object" + }, + "PromptEnhanceRequest": { + "additionalProperties": false, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "constraints": { + "$ref": "#/components/schemas/Constraints" + }, + "context": { + "$ref": "#/components/schemas/PromptContext" + }, + "lane": { + "$ref": "#/components/schemas/Lane" + }, + "mode": { + "$ref": "#/components/schemas/Mode" + }, + "profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Profile Id" + }, + "prompt": { + "minLength": 1, + "title": "Prompt", + "type": "string" + }, + "schema_version": { + "default": "2026-02-06", + "title": "Schema Version", + "type": "string" + }, + "trace": { + "$ref": "#/components/schemas/Trace" + } + }, + "required": ["lane", "prompt", "mode"], + "title": "PromptEnhanceRequest", + "type": "object" + }, + "PromptEnhanceResponse": { + "additionalProperties": false, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "diff": { + "anyOf": [ + { + "$ref": "#/components/schemas/DiffInfo" + }, + { + "type": "null" + } + ] + }, + "enhanced_prompt": { + "title": "Enhanced Prompt", + "type": "string" + }, + "enhancement_id": { + "title": "Enhancement Id", + "type": "string" + }, + "errors": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ErrorItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Errors" + }, + "lane": { + "title": "Lane", + "type": "string" + }, + "mode": { + "title": "Mode", + "type": "string" + }, + "mutations": { + "items": { + "$ref": "#/components/schemas/MutationInfo" + }, + "title": "Mutations", + "type": "array" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "original_prompt": { + "title": "Original Prompt", + "type": "string" + }, + "policy": { + "$ref": "#/components/schemas/PolicyInfo" + }, + "profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Profile Id" + }, + "provenance": { + "$ref": "#/components/schemas/Provenance" + }, + "rationale": { + "items": { + "type": "string" + }, + "title": "Rationale", + "type": "array" + }, + "redactions": { + "items": { + "$ref": "#/components/schemas/RedactionInfo" + }, + "title": "Redactions", + "type": "array" + }, + "safety_flags": { + "items": { + "type": "string" + }, + "title": "Safety Flags", + "type": "array" + }, + "schema_version": { + "default": "2026-02-06", + "title": "Schema Version", + "type": "string" + }, + "stats": { + "$ref": "#/components/schemas/Stats" + } + }, + "required": [ + "ok", + "enhancement_id", + "original_prompt", + "enhanced_prompt", + "mode", + "lane", + "policy", + "stats", + "provenance" + ], + "title": "PromptEnhanceResponse", + "type": "object" + }, + "Provenance": { + "additionalProperties": false, + "properties": { + "bridge_version": { + "title": "Bridge Version", + "type": "string" + }, + "generated_utc": { + "title": "Generated Utc", + "type": "string" + }, + "retrieval_mode": { + "title": "Retrieval Mode", + "type": "string" + }, + "run_id": { + "title": "Run Id", + "type": "string" + } + }, + "required": [ + "generated_utc", + "bridge_version", + "retrieval_mode", + "run_id" + ], + "title": "Provenance", + "type": "object" + }, + "RedactionInfo": { + "additionalProperties": false, + "properties": { + "note": { + "title": "Note", + "type": "string" + }, + "type": { + "enum": ["secret_like", "credential", "token"], + "title": "Type", + "type": "string" + } + }, + "required": ["type", "note"], + "title": "RedactionInfo", + "type": "object" + }, + "Stats": { + "additionalProperties": false, + "properties": { + "chars_after": { + "title": "Chars After", + "type": "integer" + }, + "chars_before": { + "title": "Chars Before", + "type": "integer" + }, + "est_tokens_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Est Tokens After" + }, + "est_tokens_before": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Est Tokens Before" + } + }, + "required": ["chars_before", "chars_after"], + "title": "Stats", + "type": "object" + }, + "Trace": { + "additionalProperties": false, + "properties": { + "client_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Version" + }, + "source": { + "default": "openbrowser", + "enum": ["openbrowser", "mcp", "cli"], + "title": "Source", + "type": "string" + } + }, + "title": "Trace", + "type": "object" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": ["loc", "msg", "type"], + "title": "ValidationError", + "type": "object" + }, + "WebFetchRequest": { + "properties": { + "lane": { + "default": "OB_OFFLINE", + "description": "OpenBrowser lane identifier (OB_OFFLINE or OB_ONLINE_PULSE)", + "title": "Lane", + "type": "string" + }, + "max_bytes": { + "default": 512000, + "description": "Maximum bytes to fetch", + "title": "Max Bytes", + "type": "integer" + }, + "prompt": { + "default": "", + "description": "Extraction prompt (optional)", + "title": "Prompt", + "type": "string" + }, + "url": { + "description": "URL to fetch (http/https)", + "title": "Url", + "type": "string" + } + }, + "required": ["url"], + "title": "WebFetchRequest", + "type": "object" + } + } + }, + "info": { + "title": "SOCA OpenBrowser Bridge", + "version": "v1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/health": { + "get": { + "operationId": "health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Health Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health" + } + }, + "/soca/context-pack": { + "post": { + "operationId": "context_pack_soca_context_pack_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContextPackRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContextPackResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Context Pack" + } + }, + "/soca/context7/get-library-docs": { + "post": { + "operationId": "soca_context7_get_library_docs_soca_context7_get_library_docs_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Context7DocsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Soca Context7 Get Library Docs Soca Context7 Get Library Docs Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Soca Context7 Get Library Docs" + } + }, + "/soca/github/get": { + "post": { + "operationId": "soca_github_get_soca_github_get_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GitHubGetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Soca Github Get Soca Github Get Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Soca Github Get" + } + }, + "/soca/nt2l/plan": { + "post": { + "operationId": "soca_nt2l_plan_soca_nt2l_plan_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nt2lPlanBridgeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Soca Nt2L Plan Soca Nt2L Plan Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Soca Nt2L Plan" + } + }, + "/soca/openbrowser/panel-dump": { + "post": { + "operationId": "soca_openbrowser_panel_dump_soca_openbrowser_panel_dump_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenBrowserPanelDumpRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Soca Openbrowser Panel Dump Soca Openbrowser Panel Dump Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Soca Openbrowser Panel Dump" + } + }, + "/soca/promptbuddy/capabilities": { + "get": { + "operationId": "promptbuddy_capabilities_soca_promptbuddy_capabilities_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Promptbuddy Capabilities Soca Promptbuddy Capabilities Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Promptbuddy Capabilities" + } + }, + "/soca/promptbuddy/enhance": { + "post": { + "operationId": "promptbuddy_enhance_soca_promptbuddy_enhance_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptEnhanceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptEnhanceResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Promptbuddy Enhance" + } + }, + "/soca/promptbuddy/health": { + "get": { + "operationId": "promptbuddy_health_soca_promptbuddy_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Promptbuddy Health Soca Promptbuddy Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Promptbuddy Health" + } + }, + "/soca/promptbuddy/profiles": { + "get": { + "operationId": "promptbuddy_profiles_soca_promptbuddy_profiles_get", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Promptbuddy Profiles Soca Promptbuddy Profiles Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Promptbuddy Profiles" + } + }, + "/soca/promptbuddy/selftest": { + "get": { + "operationId": "promptbuddy_selftest_soca_promptbuddy_selftest_get", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Promptbuddy Selftest Soca Promptbuddy Selftest Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Promptbuddy Selftest" + } + }, + "/soca/webfetch": { + "post": { + "operationId": "soca_webfetch_soca_webfetch_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebFetchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Soca Webfetch Soca Webfetch Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Soca Webfetch" + } + }, + "/v1/chat/completions": { + "post": { + "operationId": "chat_completions_v1_chat_completions_post", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Chat Completions" + } + }, + "/v1/models": { + "get": { + "operationId": "list_models_v1_models_get", + "parameters": [ + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response List Models V1 Models Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Models" + } + } + } +} diff --git a/bridge/promptbuddy_evidence.py b/bridge/promptbuddy_evidence.py new file mode 100644 index 0000000..b3231d6 --- /dev/null +++ b/bridge/promptbuddy_evidence.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Dict, Optional + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def _find_repo_root(start: Path) -> Optional[Path]: + current = start + for _ in range(12): + if ( + (current / "runs").is_dir() + and (current / "core").is_dir() + and (current / ".git").exists() + ): + return current + if current.parent == current: + return None + current = current.parent + return None + + +def _runs_root() -> Path: + override = os.environ.get("SOCA_RUNS_ROOT", "").strip() + if override: + return Path(override).expanduser().resolve() + + repo_root = _find_repo_root(Path(__file__).resolve()) + if repo_root: + return (repo_root / "runs").resolve() + return (Path.cwd() / "runs").resolve() + + +def _write_json(path: Path, payload: Dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + +def write_evidence_bundle( + *, + enhancement_id: str, + req: Dict[str, Any], + resp: Dict[str, Any], + meta: Optional[Dict[str, Any]] = None, + env_context: Optional[Dict[str, Any]] = None, +) -> Path: + utc_now = dt.datetime.now(dt.timezone.utc) + out_dir = _runs_root() / utc_now.strftime("%Y/%m/%d") / "promptbuddy" / enhancement_id + out_dir.mkdir(parents=True, exist_ok=True) + + _write_json(out_dir / "request.json", req) + _write_json(out_dir / "response.json", resp) + _write_json(out_dir / "meta.json", meta or {}) + _write_json( + out_dir / "env_context.json", + env_context + or { + "approval_policy": os.environ.get("SOCA_APPROVAL_POLICY", "UNKNOWN"), + "sandbox_mode": os.environ.get("SOCA_SANDBOX_MODE", "UNKNOWN"), + "network_access": os.environ.get("SOCA_NETWORK_ACCESS", "UNKNOWN"), + }, + ) + + files = sorted([p for p in out_dir.glob("*") if p.is_file() and p.name != "sha256.txt"]) + manifest = "\n".join(f"{_sha256_file(p)} {p.name}" for p in files) + "\n" + (out_dir / "sha256.txt").write_text(manifest, encoding="utf-8") + return out_dir diff --git a/bridge/promptbuddy_models.py b/bridge/promptbuddy_models.py new file mode 100644 index 0000000..d42844d --- /dev/null +++ b/bridge/promptbuddy_models.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +try: + from .version import PROMPTBUDDY_SCHEMA_VERSION +except ImportError: # pragma: no cover - script execution fallback + from version import PROMPTBUDDY_SCHEMA_VERSION # type: ignore + + +class Lane(str, Enum): + OB_OFFLINE = "OB_OFFLINE" + OB_ONLINE_PULSE = "OB_ONLINE_PULSE" + + +class Mode(str, Enum): + clarify = "clarify" + structure = "structure" + compress = "compress" + persona = "persona" + safe_exec = "safe_exec" + + +class PromptContext(BaseModel): + model_config = ConfigDict(extra="forbid") + + tab_url: Optional[str] = None + tab_title: Optional[str] = None + intent: Optional[str] = None + target_base_url: Optional[str] = None + + +class Constraints(BaseModel): + model_config = ConfigDict(extra="forbid") + + max_chars: Optional[int] = Field(default=None, ge=1, le=200_000) + keep_language: bool = True + preserve_code_blocks: bool = True + allow_online_enrichment: bool = False + + +class Trace(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: Literal["openbrowser", "mcp", "cli"] = "openbrowser" + client_version: Optional[str] = None + + +class PromptEnhanceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + api_version: Literal["v1"] = "v1" + schema_version: str = PROMPTBUDDY_SCHEMA_VERSION + + lane: Lane + prompt: str = Field(min_length=1) + mode: Mode + profile_id: Optional[str] = None + + context: PromptContext = Field(default_factory=PromptContext) + constraints: Constraints = Field(default_factory=Constraints) + trace: Trace = Field(default_factory=Trace) + + +class MutationInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["reorder", "clarify", "add_constraints", "compress", "persona", "safety", "profile"] + note: str + + +class RedactionInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["secret_like", "credential", "token"] + note: str + + +class ErrorItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: str + message: str + + +class Provenance(BaseModel): + model_config = ConfigDict(extra="forbid") + + generated_utc: str + bridge_version: str + retrieval_mode: str + run_id: str + + +class PolicyInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + lane_allowed: bool + network_used: bool + model: str + + +class DiffInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["spans", "unified"] = "unified" + data: Any + + +class Stats(BaseModel): + model_config = ConfigDict(extra="forbid") + + chars_before: int + chars_after: int + est_tokens_before: Optional[int] = None + est_tokens_after: Optional[int] = None + + +class PromptEnhanceResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + api_version: Literal["v1"] = "v1" + schema_version: str = PROMPTBUDDY_SCHEMA_VERSION + + ok: bool + enhancement_id: str + original_prompt: str + enhanced_prompt: str + + rationale: List[str] = Field(default_factory=list) + mutations: List[MutationInfo] = Field(default_factory=list) + redactions: List[RedactionInfo] = Field(default_factory=list) + safety_flags: List[str] = Field(default_factory=list) + + mode: str + lane: str + profile_id: Optional[str] = None + + policy: PolicyInfo + diff: Optional[DiffInfo] = None + stats: Stats + provenance: Provenance + + errors: Optional[List[ErrorItem]] = None diff --git a/bridge/promptbuddy_routes.py b/bridge/promptbuddy_routes.py new file mode 100644 index 0000000..8520ca7 --- /dev/null +++ b/bridge/promptbuddy_routes.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import datetime as dt +import json +import os +import traceback +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +from fastapi import APIRouter, Header, HTTPException + +try: + from .promptbuddy_evidence import write_evidence_bundle + from .promptbuddy_models import ( + ErrorItem, + Mode, + MutationInfo, + PolicyInfo, + PromptEnhanceRequest, + PromptEnhanceResponse, + Provenance, + RedactionInfo, + Stats, + ) + from .promptbuddy_service import enhance_prompt_local, estimate_stats + from .promptbuddy_static_gate import check_promptbuddy_offline_static_gate + from .version import BRIDGE_VERSION, PROMPTBUDDY_SCHEMA_VERSION +except ImportError: # pragma: no cover - script execution fallback + from promptbuddy_evidence import write_evidence_bundle # type: ignore + from promptbuddy_models import ( # type: ignore + ErrorItem, + Mode, + MutationInfo, + PolicyInfo, + PromptEnhanceRequest, + PromptEnhanceResponse, + Provenance, + RedactionInfo, + Stats, + ) + from promptbuddy_service import enhance_prompt_local, estimate_stats # type: ignore + from promptbuddy_static_gate import check_promptbuddy_offline_static_gate # type: ignore + from version import BRIDGE_VERSION, PROMPTBUDDY_SCHEMA_VERSION # type: ignore + + +router = APIRouter() +_LOCAL_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +def _find_repo_root(start: Path) -> Optional[Path]: + current = start + for _ in range(12): + if ( + (current / "runs").is_dir() + and (current / "core").is_dir() + and (current / ".git").exists() + ): + return current + if current.parent == current: + return None + current = current.parent + return None + + +def _repo_root() -> Path: + root = _find_repo_root(Path(__file__).resolve()) + return root if root else Path.cwd() + + +def _profiles_root() -> Path: + override = os.environ.get("SOCA_PROMPTBUDDY_PROFILES_DIR", "").strip() + if override: + return Path(override).expanduser().resolve() + return (_repo_root() / "core" / "promptbuddy" / "profiles").resolve() + + +def _hostname_from_url(raw_url: Optional[str]) -> Optional[str]: + if not raw_url: + return None + try: + parsed = urlparse(raw_url) + except Exception: + return None + return (parsed.hostname or "").strip().lower() or None + + +def _is_local_hostname(hostname: Optional[str]) -> bool: + if not hostname: + return False + if hostname in _LOCAL_HOSTS: + return True + parts = hostname.split(".") + if len(parts) == 4 and all(p.isdigit() for p in parts): + nums = [int(p) for p in parts] + if nums[0] == 10: + return True + if nums[0] == 127: + return True + if nums[0] == 192 and nums[1] == 168: + return True + if nums[0] == 172 and 16 <= nums[1] <= 31: + return True + if nums[0] == 100 and 64 <= nums[1] <= 127: + return True + return False + + +def _require_token(authorization: Optional[str]) -> None: + expected = os.environ.get("SOCA_OPENBROWSER_BRIDGE_TOKEN", "soca").strip() + if not expected: + return + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = authorization.split(" ", 1)[1].strip() + if token != expected: + raise HTTPException(status_code=403, detail="invalid bearer token") + + +def _load_profiles() -> List[Dict[str, Any]]: + root = _profiles_root() + if not root.exists(): + return [] + profiles: List[Dict[str, Any]] = [] + for file in sorted(root.glob("*.json")): + try: + payload = json.loads(file.read_text(encoding="utf-8")) + except Exception: + continue + if isinstance(payload, dict): + payload.setdefault("id", file.stem) + profiles.append(payload) + return profiles + + +def _profiles_by_id() -> Dict[str, Dict[str, Any]]: + result: Dict[str, Dict[str, Any]] = {} + for profile in _load_profiles(): + profile_id = str(profile.get("id", "")).strip() + if profile_id: + result[profile_id] = profile + return result + + +def _offline_constraints_guard(req: PromptEnhanceRequest) -> None: + if req.lane.value != "OB_OFFLINE": + return + if req.constraints.allow_online_enrichment: + raise HTTPException(status_code=403, detail="ob_offline_rejects_online_enrichment") + target_host = _hostname_from_url(req.context.target_base_url) + if target_host and not _is_local_hostname(target_host): + raise HTTPException(status_code=403, detail=f"ob_offline_rejects_non_local_target:{target_host}") + + +@router.get("/soca/promptbuddy/health") +async def promptbuddy_health() -> Dict[str, Any]: + return { + "ok": True, + "bridge_version": BRIDGE_VERSION, + "schema_version": PROMPTBUDDY_SCHEMA_VERSION, + "profiles_dir": str(_profiles_root()), + "local_only": True, + } + + +@router.get("/soca/promptbuddy/capabilities") +async def promptbuddy_capabilities() -> Dict[str, Any]: + return { + "ok": True, + "modes": [mode.value for mode in Mode], + "constraints": ["max_chars", "keep_language", "preserve_code_blocks", "allow_online_enrichment"], + "lanes": ["OB_OFFLINE", "OB_ONLINE_PULSE"], + "schema_version": PROMPTBUDDY_SCHEMA_VERSION, + } + + +@router.get("/soca/promptbuddy/profiles") +async def promptbuddy_profiles(authorization: Optional[str] = Header(default=None, alias="Authorization")) -> Dict[str, Any]: + _require_token(authorization) + return {"ok": True, "profiles": _load_profiles()} + + +@router.get("/soca/promptbuddy/selftest") +async def promptbuddy_selftest(authorization: Optional[str] = Header(default=None, alias="Authorization")) -> Dict[str, Any]: + _require_token(authorization) + + payload: Dict[str, Any] = { + "ok": True, + "bridge_version": BRIDGE_VERSION, + "static_gate": {"ok": True, "violations": []}, + "offline_dry_run": {"ok": True}, + } + + violations = check_promptbuddy_offline_static_gate() + if violations: + payload["ok"] = False + payload["static_gate"]["ok"] = False + payload["static_gate"]["violations"] = [ + {"file": v.file, "lineno": v.lineno, "module": v.module, "reason": v.reason} + for v in violations + ] + + try: + req = PromptEnhanceRequest( + lane="OB_OFFLINE", + prompt="Selftest prompt for deterministic prompt enhancement.", + mode="structure", + trace={"source": "cli"}, + ) + enhanced, rationale, _mutations, redactions, flags, model_name, _diff = await enhance_prompt_local(req) + payload["offline_dry_run"] = { + "ok": True, + "model": model_name, + "chars_after": len(enhanced), + "rationale": rationale[:3], + "redactions": [r.model_dump() for r in redactions], + "safety_flags": flags, + } + except Exception: + payload["ok"] = False + payload["offline_dry_run"] = { + "ok": False, + "error": traceback.format_exc().splitlines()[-1], + } + + return payload + + +@router.post( + "/soca/promptbuddy/enhance", + response_model=PromptEnhanceResponse, + response_model_exclude_none=True, +) +async def promptbuddy_enhance( + req: PromptEnhanceRequest, + authorization: Optional[str] = Header(default=None, alias="Authorization"), +) -> PromptEnhanceResponse: + _require_token(authorization) + _offline_constraints_guard(req) + + profile_map = _profiles_by_id() + profile: Optional[Dict[str, Any]] = None + if req.profile_id: + profile = profile_map.get(req.profile_id) + if profile is None: + raise HTTPException(status_code=400, detail=f"unknown_profile_id:{req.profile_id}") + + enhancement_id = str(uuid.uuid4()) + now = dt.datetime.now(dt.timezone.utc).isoformat() + network_used = False + + try: + ( + enhanced_prompt, + rationale, + mutations, + redactions, + safety_flags, + model_name, + diff, + ) = await enhance_prompt_local(req, profile=profile) + errors: Optional[List[ErrorItem]] = None + ok = True + except Exception as exc: + enhanced_prompt = req.prompt + rationale = [] + mutations = [MutationInfo(type="safety", note="enhancement_failed_fallback")] + redactions = [] + safety_flags = ["enhance_failed"] + model_name = "local:unavailable" + diff = None + errors = [ErrorItem(code="ENHANCE_FAILED", message=str(exc))] + ok = False + + stat_values = estimate_stats(req.prompt, enhanced_prompt) + response = PromptEnhanceResponse( + ok=ok, + enhancement_id=enhancement_id, + original_prompt=req.prompt, + enhanced_prompt=enhanced_prompt, + rationale=rationale, + mutations=mutations, + redactions=redactions, + safety_flags=safety_flags, + mode=req.mode.value, + lane=req.lane.value, + profile_id=req.profile_id, + policy=PolicyInfo(lane_allowed=True, network_used=network_used, model=model_name), + diff=diff, + stats=Stats(**stat_values), + provenance=Provenance( + generated_utc=now, + bridge_version=BRIDGE_VERSION, + retrieval_mode="local_only", + run_id=enhancement_id, + ), + errors=errors, + ) + + write_evidence_bundle( + enhancement_id=enhancement_id, + req=req.model_dump(), + resp=response.model_dump(exclude_none=False), + meta={ + "bridge_version": BRIDGE_VERSION, + "schema_version": PROMPTBUDDY_SCHEMA_VERSION, + "lane": req.lane.value, + "mode": req.mode.value, + "model": model_name, + "network_used": network_used, + "ok": ok, + }, + ) + return response diff --git a/bridge/promptbuddy_service.py b/bridge/promptbuddy_service.py new file mode 100644 index 0000000..b75ef9e --- /dev/null +++ b/bridge/promptbuddy_service.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import difflib +import math +import re +from typing import Any, Dict, List, Optional, Tuple + +try: + from .promptbuddy_models import ( + DiffInfo, + Mode, + MutationInfo, + PromptEnhanceRequest, + RedactionInfo, + ) +except ImportError: # pragma: no cover - script execution fallback + from promptbuddy_models import ( # type: ignore + DiffInfo, + Mode, + MutationInfo, + PromptEnhanceRequest, + RedactionInfo, + ) + + +_SECRET_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [ + ("token", re.compile(r"\b(sk-[A-Za-z0-9_-]{12,})\b")), + ("credential", re.compile(r"(?i)\b(api[_-]?key|password|secret)\s*[:=]\s*([^\s,;\"']+)")), + ("token", re.compile(r"(?i)\b(token|bearer)\s*[:=]\s*([^\s,;\"']+)")), +] + + +def _estimate_tokens(text: str) -> int: + return max(1, math.ceil(len(text) / 4)) + + +def _truncate(text: str, max_chars: Optional[int]) -> str: + if max_chars is None or len(text) <= max_chars: + return text + return f"{text[: max_chars - 3]}..." + + +def _build_diff(original: str, enhanced: str, max_lines: int = 250) -> DiffInfo: + lines = list( + difflib.unified_diff( + original.splitlines(keepends=True), + enhanced.splitlines(keepends=True), + fromfile="original", + tofile="enhanced", + ) + ) + if len(lines) > max_lines: + lines = lines[:max_lines] + ["\n... (diff truncated)\n"] + return DiffInfo(type="unified", data="".join(lines)) + + +def _compress_plain_text(text: str) -> str: + return " ".join(text.split()) + + +def _compress_preserving_code_fences(text: str) -> str: + chunks = text.split("```") + if len(chunks) == 1: + return _compress_plain_text(text) + + rebuilt: List[str] = [] + for idx, chunk in enumerate(chunks): + if idx % 2 == 0: + rebuilt.append(_compress_plain_text(chunk)) + else: + rebuilt.append(chunk.strip("\n")) + return "```".join(rebuilt).strip() + + +def _redact_secret_like_text(text: str) -> Tuple[str, List[RedactionInfo]]: + redactions: List[RedactionInfo] = [] + redacted = text + for redaction_type, pattern in _SECRET_PATTERNS: + if not pattern.search(redacted): + continue + if redaction_type == "credential": + redacted = pattern.sub(lambda m: f"{m.group(1)}=[REDACTED]", redacted) + else: + redacted = pattern.sub("[REDACTED]", redacted) + redactions.append( + RedactionInfo( + type=redaction_type, # type: ignore[arg-type] + note=f"removed_{redaction_type}_like_pattern", + ) + ) + return redacted, redactions + + +def _profile_style_rules(profile: Optional[Dict[str, Any]]) -> List[str]: + if not isinstance(profile, dict): + return [] + rules = profile.get("style_rules") + if not isinstance(rules, list): + return [] + return [str(rule).strip() for rule in rules if str(rule).strip()] + + +def _apply_mode( + req: PromptEnhanceRequest, + prompt: str, + profile: Optional[Dict[str, Any]], +) -> Tuple[str, List[str], List[MutationInfo], List[str]]: + mode = req.mode + rationale: List[str] = [f"mode={mode.value}", "local_only", "deterministic_v1"] + mutations: List[MutationInfo] = [] + safety_flags: List[str] = [] + + style_rules = _profile_style_rules(profile) + intent = req.context.intent or "N/A" + tab_title = req.context.tab_title or "N/A" + tab_url = req.context.tab_url or "N/A" + + if mode == Mode.compress: + if req.constraints.preserve_code_blocks: + enhanced = _compress_preserving_code_fences(prompt) + else: + enhanced = _compress_plain_text(prompt) + rationale.append("compressed_whitespace") + mutations.append(MutationInfo(type="compress", note="reduced_whitespace_and_noise")) + elif mode == Mode.structure: + rules_block = "\n".join(f"- {rule}" for rule in style_rules) or "- Be precise and actionable." + enhanced = ( + "## Goal\n" + f"{prompt}\n\n" + "## Context / Inputs\n" + f"- intent: {intent}\n" + f"- tab_title: {tab_title}\n" + f"- tab_url: {tab_url}\n\n" + "## Constraints\n" + f"{rules_block}\n" + "- List assumptions explicitly.\n" + "- Keep output deterministic.\n\n" + "## Output Format\n" + "1. Final answer first.\n" + "2. Short rationale/checklist.\n" + ) + rationale.append("added_structure_sections") + mutations.append(MutationInfo(type="reorder", note="reframed_prompt_into_sections")) + mutations.append(MutationInfo(type="add_constraints", note="inserted_output_constraints")) + elif mode == Mode.clarify: + enhanced = ( + f"{prompt}\n\n" + "If key details are missing, ask up to 5 clarifying questions:\n" + "1. What is the exact goal and success criteria?\n" + "2. What constraints are non-negotiable?\n" + "3. What environment/context should be assumed?\n" + "4. What risks must be avoided?\n" + "5. What output format is required?\n\n" + "If no clarifications are required, proceed with explicit assumptions." + ) + rationale.append("added_clarification_block") + mutations.append(MutationInfo(type="clarify", note="added_pre_answer_questions")) + elif mode == Mode.persona: + persona_header = "You are a rigorous, evidence-first SOCA assistant." + if profile and isinstance(profile.get("persona"), str): + persona_header = str(profile["persona"]).strip() or persona_header + enhanced = ( + f"{persona_header}\n" + "Use deterministic reasoning, explicit assumptions, and concise outputs.\n\n" + f"{prompt}" + ) + rationale.append("added_persona_header") + mutations.append(MutationInfo(type="persona", note="prepended_persona_constraints")) + else: # Mode.safe_exec + enhanced = ( + "Safety / execution constraints:\n" + "- Never expose or request secrets.\n" + "- Avoid destructive actions without explicit confirmation.\n" + "- Prefer read-only or dry-run first.\n\n" + f"{prompt}\n\n" + "Return:\n" + "1. Safe plan\n" + "2. Minimal command set (dry-run first)\n" + "3. Rollback steps\n" + ) + safety_flags.append("safe_exec") + rationale.append("added_safe_execution_guardrails") + mutations.append(MutationInfo(type="safety", note="inserted_safe_execution_policy")) + + if style_rules: + mutations.append(MutationInfo(type="profile", note=f"applied_profile_rules={len(style_rules)}")) + rationale.append("applied_profile_style_rules") + + return enhanced.strip(), rationale, mutations, safety_flags + + +async def enhance_prompt_local( + req: PromptEnhanceRequest, + profile: Optional[Dict[str, Any]] = None, +) -> Tuple[str, List[str], List[MutationInfo], List[RedactionInfo], List[str], str, DiffInfo]: + original = req.prompt.strip() + prompt_for_mode = original + redactions: List[RedactionInfo] = [] + + if req.mode == Mode.safe_exec: + prompt_for_mode, redactions = _redact_secret_like_text(prompt_for_mode) + + enhanced, rationale, mutations, safety_flags = _apply_mode(req, prompt_for_mode, profile) + enhanced = _truncate(enhanced, req.constraints.max_chars) + diff = _build_diff(req.prompt, enhanced) + return ( + enhanced, + rationale, + mutations, + redactions, + safety_flags, + "local:deterministic_v1", + diff, + ) + + +def estimate_stats(before: str, after: str) -> Dict[str, int]: + return { + "chars_before": len(before), + "chars_after": len(after), + "est_tokens_before": _estimate_tokens(before), + "est_tokens_after": _estimate_tokens(after), + } diff --git a/bridge/promptbuddy_static_gate.py b/bridge/promptbuddy_static_gate.py new file mode 100644 index 0000000..f1c5431 --- /dev/null +++ b/bridge/promptbuddy_static_gate.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Set + + +@dataclass(frozen=True) +class ImportViolation: + file: str + lineno: int + module: str + reason: str + + +FORBIDDEN_TOPLEVEL: Set[str] = {"requests", "httpx", "socket", "urllib3"} +FORBIDDEN_SUBMODULES: Set[str] = {"urllib.request", "urllib.response"} + + +def scan_forbidden_imports(paths: Iterable[Path]) -> List[ImportViolation]: + violations: List[ImportViolation] = [] + for path in paths: + if not path.exists(): + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + module = alias.name + top = module.split(".")[0] + if module in FORBIDDEN_SUBMODULES: + violations.append( + ImportViolation( + file=str(path), + lineno=getattr(node, "lineno", 0), + module=module, + reason="forbidden urllib network submodule", + ) + ) + elif top in FORBIDDEN_TOPLEVEL: + violations.append( + ImportViolation( + file=str(path), + lineno=getattr(node, "lineno", 0), + module=module, + reason="forbidden network module", + ) + ) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + top = module.split(".")[0] if module else "" + if module in FORBIDDEN_SUBMODULES: + violations.append( + ImportViolation( + file=str(path), + lineno=getattr(node, "lineno", 0), + module=module, + reason="forbidden urllib network submodule", + ) + ) + elif top in FORBIDDEN_TOPLEVEL: + violations.append( + ImportViolation( + file=str(path), + lineno=getattr(node, "lineno", 0), + module=module, + reason="forbidden network module", + ) + ) + return violations + + +def check_promptbuddy_offline_static_gate() -> List[ImportViolation]: + targets = [ + Path("core/tools/openbrowser/bridge/promptbuddy_models.py"), + Path("core/tools/openbrowser/bridge/promptbuddy_service.py"), + Path("core/tools/openbrowser/bridge/promptbuddy_evidence.py"), + ] + return scan_forbidden_imports(targets) diff --git a/bridge/version.py b/bridge/version.py new file mode 100644 index 0000000..3b42d82 --- /dev/null +++ b/bridge/version.py @@ -0,0 +1,6 @@ +""" +SSOT version constants for the SOCA OpenBrowser Bridge. +""" + +BRIDGE_VERSION = "v1.0.0" +PROMPTBUDDY_SCHEMA_VERSION = "2026-02-06" diff --git a/chromium-extension/e2e/fixtures.ts b/chromium-extension/e2e/fixtures.ts new file mode 100644 index 0000000..be20aed --- /dev/null +++ b/chromium-extension/e2e/fixtures.ts @@ -0,0 +1,72 @@ +import { + test as base, + chromium, + type BrowserContext, + type Page, + type Worker +} from "playwright/test"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +type Fixtures = { + context: BrowserContext; + background: Worker; + extensionId: string; + extPage: Page; +}; + +export const test = base.extend({ + context: async ({}, use) => { + const extPath = path.resolve(__dirname, "..", "dist"); + const manifestPath = path.join(extPath, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error( + `Extension dist missing. Build first: pnpm -C core/tools/openbrowser/chromium-extension build (missing ${manifestPath})` + ); + } + + const userDataDir = fs.mkdtempSync( + path.join(os.tmpdir(), "soca-openbrowser-e2e-") + ); + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${extPath}`, + `--load-extension=${extPath}` + ] + }); + + try { + await use(context); + } finally { + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }, + + background: async ({ context }, use) => { + const isExtSW = (w: Worker) => w.url().startsWith("chrome-extension://"); + let bg = context.serviceWorkers().find(isExtSW); + if (!bg) { + bg = await context.waitForEvent("serviceworker", isExtSW); + } + await use(bg); + }, + + extensionId: async ({ background }, use) => { + const url = background.url(); + const id = url.split("/")[2]; + await use(id); + }, + + extPage: async ({ context, extensionId }, use) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/options.html`); + await page.waitForLoadState("domcontentloaded"); + await use(page); + await page.close(); + } +}); + +export { expect } from "playwright/test"; diff --git a/chromium-extension/e2e/no-egress.spec.ts b/chromium-extension/e2e/no-egress.spec.ts new file mode 100644 index 0000000..9f55836 --- /dev/null +++ b/chromium-extension/e2e/no-egress.spec.ts @@ -0,0 +1,150 @@ +import http from "http"; +import { test, expect } from "./fixtures"; + +async function extSendMessage(extPage: any, msg: any): Promise { + const out = await extPage.evaluate( + (m: any) => + new Promise((resolve) => { + try { + chrome.runtime.sendMessage(m, (resp) => { + const err = chrome.runtime.lastError?.message || null; + resolve({ resp, err }); + }); + } catch (e: any) { + resolve({ resp: null, err: String(e?.message || e) }); + } + }), + msg + ); + if (out?.err) return { ok: false, err: out.err }; + return out?.resp; +} + +test("SW cannot fetch public internet (mechanical no-egress)", async ({ + extPage +}) => { + const resp = await extSendMessage(extPage, { + type: "SOCA_TEST_TRY_FETCH", + url: "https://example.com" + }); + expect(resp?.ok).toBe(true); +}); + +test("Bridge calls are token gated and work against localhost", async ({ + extPage +}) => { + const server = http.createServer((req, res) => { + try { + if (req.url === "/v1/models") { + const auth = String(req.headers["authorization"] || ""); + if (auth !== "Bearer test") { + res.writeHead(403, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_token" })); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + object: "list", + data: [ + { + id: "mock-model", + object: "model", + created: 0, + owned_by: "mock" + } + ] + }) + ); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); + } catch { + res.writeHead(500); + res.end(); + } + }); + + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()) + ); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : null; + if (!port) throw new Error("failed_to_bind_mock_bridge"); + const bridgeBaseURL = `http://127.0.0.1:${port}`; + + try { + const setCfg = await extSendMessage(extPage, { + type: "SOCA_SET_BRIDGE_CONFIG", + config: { bridgeBaseURL, dnrGuardrailsEnabled: true } + }); + expect(setCfg?.ok).toBe(true); + + const clearTok = await extSendMessage(extPage, { + type: "SOCA_SET_BRIDGE_TOKEN", + token: "" + }); + expect(clearTok?.ok).toBe(true); + + const noTok = await extSendMessage(extPage, { + type: "SOCA_BRIDGE_GET_MODELS" + }); + expect(noTok?.ok).toBe(false); + expect(String(noTok?.err || "")).toContain("bridge_token_missing"); + + const setTok = await extSendMessage(extPage, { + type: "SOCA_SET_BRIDGE_TOKEN", + token: "test" + }); + expect(setTok?.ok).toBe(true); + + const ok = await extSendMessage(extPage, { + type: "SOCA_BRIDGE_GET_MODELS" + }); + expect(ok?.ok).toBe(true); + expect(Array.isArray(ok?.data?.data)).toBe(true); + } finally { + server.close(); + } +}); + +test("Write gate blocks on pageSigHash mismatch (deterministic fail-closed reason)", async ({ + extPage +}) => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(` + + + + SOCA E2E Write Gate + + +

Write Gate

+ + +`); + }); + + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()) + ); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : null; + if (!port) throw new Error("failed_to_bind_local_test_page"); + const url = `http://127.0.0.1:${port}/`; + + try { + const resp = await extSendMessage(extPage, { + type: "SOCA_TEST_WRITE_GATE_BLOCK_REASON", + url + }); + expect(resp?.ok).toBe(true); + expect(String(resp?.reason || "")).toContain( + "fail_closed:pageSigHash_mismatch" + ); + } finally { + server.close(); + } +}); diff --git a/chromium-extension/package.json b/chromium-extension/package.json index 17d7bdf..f7a2a85 100644 --- a/chromium-extension/package.json +++ b/chromium-extension/package.json @@ -1,9 +1,11 @@ { "name": "@openbrowser-ai/openbrowser", - "version": "1.0.0", + "version": "1.0.7", "description": "OpenBrowser Agent", "scripts": { - "build": "webpack --config webpack.config.js" + "build": "webpack --config webpack.config.js", + "check:drift": "bash scripts/assert_no_all_urls.sh && bash scripts/assert_no_models_dev.sh", + "test:e2e": "../../../../node_modules/.bin/playwright test -c playwright.config.ts" }, "author": "OpenBrowserAI", "license": "MIT", diff --git a/chromium-extension/playwright.config.ts b/chromium-extension/playwright.config.ts new file mode 100644 index 0000000..10267b6 --- /dev/null +++ b/chromium-extension/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "playwright/test"; + +export default defineConfig({ + testDir: "e2e", + testMatch: /.*\.spec\.ts/, + fullyParallel: false, + workers: 1, + timeout: 90_000, + // NOTE: extension tests create their own persistent Chromium context in `e2e/fixtures.ts`. + reporter: "list" +}); diff --git a/chromium-extension/public/manifest.json b/chromium-extension/public/manifest.json index 76d6b78..423ab0a 100644 --- a/chromium-extension/public/manifest.json +++ b/chromium-extension/public/manifest.json @@ -1,7 +1,7 @@ { "name": "OpenBrowser", "description": "OpenBrowser Extension", - "version": "1.0.0", + "version": "1.0.7", "manifest_version": 3, "background": { "type": "module", @@ -13,32 +13,46 @@ "128": "icon_neutral.png" }, "side_panel": { - "default_path": "sidebar.html", - "openPanelOnActionClick": true + "default_path": "sidebar.html" }, "options_ui": { "page": "options.html", "open_in_tab": true }, - "content_scripts": [ - { - "run_at": "document_idle", - "matches": [""], - "js": ["js/content_script.js"] - } - ], + "action": { + "default_title": "OpenBrowser" + }, "permissions": [ - "tabs", "activeTab", - "windows", - "sidePanel", "storage", "scripting", - "alarms", - "notifications", - "downloads" + "tabs", + "windows", + "sidePanel", + "downloads", + "declarativeNetRequest", + "webNavigation" + ], + "host_permissions": [ + "http://127.0.0.1/*", + "https://127.0.0.1/*", + "http://localhost/*", + "https://localhost/*", + "https://aistudio.google.com/*", + "https://accounts.google.com/*", + "https://lovable.dev/*", + "https://antigravity.google/*", + "https://github.com/*" + ], + "optional_host_permissions": [ + "https://api.openai.com/*", + "https://api.anthropic.com/*", + "https://generativelanguage.googleapis.com/*", + "https://openrouter.ai/*", + "https://*.openai.azure.com/*", + "https://bedrock-runtime.*.amazonaws.com/*", + "*://*/*" ], - "host_permissions": [""], "web_accessible_resources": [ { "matches": [""], diff --git a/chromium-extension/scripts/assert_no_all_urls.sh b/chromium-extension/scripts/assert_no_all_urls.sh new file mode 100755 index 0000000..4e2e26e --- /dev/null +++ b/chromium-extension/scripts/assert_no_all_urls.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +manifest="public/manifest.json" + +python3 - "$manifest" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +hosts = data.get("host_permissions") or [] +if "" in hosts: + print(f"FAIL: present in host_permissions in {path}") + sys.exit(1) + +print("OK: host_permissions has no .") +PY diff --git a/chromium-extension/scripts/assert_no_models_dev.sh b/chromium-extension/scripts/assert_no_models_dev.sh new file mode 100755 index 0000000..b26f1b6 --- /dev/null +++ b/chromium-extension/scripts/assert_no_models_dev.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +if rg -n "models\\.dev" src; then + echo "FAIL: models.dev reference found in extension source (no direct egress)." + exit 1 +fi + +echo "OK: no models.dev in extension source." diff --git a/chromium-extension/scripts/l3_run_all.sh b/chromium-extension/scripts/l3_run_all.sh new file mode 100755 index 0000000..9a03bd2 --- /dev/null +++ b/chromium-extension/scripts/l3_run_all.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# This script lives inside a nested OpenBrowser git repo at: +# /core/tools/openbrowser/chromium-extension/scripts/l3_run_all.sh +# We want the SOCA repo root (not the nested OpenBrowser root), because our paths +# in this runbook are repo-root anchored. +OPENBROWSER_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +REPO_ROOT="$(cd "$OPENBROWSER_ROOT/../../.." && pwd)" +if [[ ! -d "${REPO_ROOT}/core/tools/openbrowser" ]]; then + echo "ERROR: unable to resolve SOCA repo root from ${SCRIPT_DIR}" >&2 + exit 1 +fi + +cd "$REPO_ROOT" + +echo "[gate0] preflight: versions" +node -v +pnpm -v +python3 --version + +echo "" +echo "[gate1] build chain (core -> extension -> chromium-extension)" +pnpm -C core/tools/openbrowser/packages/core build +pnpm -C core/tools/openbrowser/packages/extension build +pnpm -C core/tools/openbrowser/chromium-extension build + +echo "" +echo "[gate2] drift gates (mechanical no-egress invariants)" +pnpm -C core/tools/openbrowser/chromium-extension check:drift + +echo "" +echo "[gate3] bridge sanity (syntax + SSOT endpoints)" +python3 -m py_compile core/tools/openbrowser/bridge/app.py + +BRIDGE_HOST="${SOCA_OPENBROWSER_BRIDGE_HOST:-127.0.0.1}" +BRIDGE_PORT="${SOCA_OPENBROWSER_BRIDGE_PORT:-9834}" +BRIDGE_BASE="http://${BRIDGE_HOST}:${BRIDGE_PORT}" + +BRIDGE_PID="" +BRIDGE_LOG="" +cleanup_bridge() { + if [[ -n "${BRIDGE_PID:-}" ]]; then + kill "${BRIDGE_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${BRIDGE_LOG:-}" ]]; then + echo "" + echo "[gate3] bridge logs: ${BRIDGE_LOG}" + fi +} +trap cleanup_bridge EXIT INT TERM + +if ! curl -sS --max-time 2 "${BRIDGE_BASE}/health" >/dev/null 2>&1; then + echo "[gate3] bridge not detected at ${BRIDGE_BASE} (starting ephemeral bridge for sanity checks)" + # macOS mktemp requires the template to end with X's (no suffix like ".log"). + tmpdir="${TMPDIR:-/tmp}" + tmpdir="${tmpdir%/}" + BRIDGE_LOG="$(mktemp "${tmpdir}/soca-openbrowser-bridge.l3.XXXXXX")" + SOCA_OPENBROWSER_BRIDGE_HOST="${BRIDGE_HOST}" \ + SOCA_OPENBROWSER_BRIDGE_PORT="${BRIDGE_PORT}" \ + python3 core/tools/openbrowser/bridge/app.py >"${BRIDGE_LOG}" 2>&1 & + BRIDGE_PID="$!" + + for _ in $(seq 1 50); do + if curl -sS --max-time 2 "${BRIDGE_BASE}/health" >/dev/null 2>&1; then + break + fi + sleep 0.2 + done +fi + +curl -s "${BRIDGE_BASE}/capabilities" | head +curl -s -H "Authorization: Bearer soca" "${BRIDGE_BASE}/soca/policy/packs" | head +curl -s -H "Authorization: Bearer soca" "${BRIDGE_BASE}/v1/models" | head + +echo "" +echo "[gate4] e2e (headed, observable)" +pnpm -C core/tools/openbrowser/chromium-extension test:e2e --headed diff --git a/chromium-extension/src/background/agent/browser-service.ts b/chromium-extension/src/background/agent/browser-service.ts index 8372335..ec679e5 100644 --- a/chromium-extension/src/background/agent/browser-service.ts +++ b/chromium-extension/src/background/agent/browser-service.ts @@ -1,8 +1,14 @@ import { BrowserService } from "@openbrowser-ai/core"; import { PageTab, PageContent } from "@openbrowser-ai/core/types"; -import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; +import { bridgeFetchJson } from "../bridge-client"; -GlobalWorkerOptions.workerSrc = chrome.runtime.getURL("pdf.worker.min.js"); +type PdfExtractResponse = { + ok?: boolean; + text?: string; + pages?: number; + sha256?: string; + truncated?: boolean; +}; export class SimpleBrowserService implements BrowserService { async loadTabs( @@ -54,7 +60,7 @@ export class SimpleBrowserService implements BrowserService { }); let tabHtmls = frameResults[0].result as string; if (!tabHtmls) { - tabHtmls = await this.extractPdfContent(tab.url); + tabHtmls = await this.extractPdfContent(tab.url || ""); } contents.push({ tabId: tabId, @@ -68,18 +74,20 @@ export class SimpleBrowserService implements BrowserService { private async extractPdfContent(pdfUrl: string): Promise { try { - const loadingTask = getDocument(pdfUrl); - const pdf = await loadingTask.promise; - let textContent = ""; - - for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { - const page = await pdf.getPage(pageNum); - const textData = await page.getTextContent(); - const pageText = textData.items.map((item: any) => item.str).join(" "); - textContent += `PDF Page ${pageNum}:\n${pageText}\n\n`; - } - - return textContent; + if (!pdfUrl) return ""; + const resolvedUrl = resolvePdfSourceUrl(pdfUrl); + if (!resolvedUrl) return ""; + // IMPORTANT: the extension does not fetch remote PDFs. All fetching/extraction happens in the Bridge. + const resp = await bridgeFetchJson( + "/soca/pdf/extract", + { + method: "POST", + body: JSON.stringify({ url: resolvedUrl }), + withLane: true, + timeoutMs: 45_000 + } + ); + return String(resp?.text || ""); } catch (error) { console.warn("Unable to load PDF:", error); return ""; @@ -87,6 +95,42 @@ export class SimpleBrowserService implements BrowserService { } } +function resolvePdfSourceUrl(rawUrl: string): string { + const text = String(rawUrl || "").trim(); + if (!text) return ""; + try { + const u = new URL(text); + if (u.protocol === "http:" || u.protocol === "https:") return u.toString(); + + // Chrome PDF viewer: chrome-extension:///index.html?file= + if (u.protocol === "chrome-extension:") { + const file = u.searchParams.get("file"); + if (!file) return ""; + const decoded = (() => { + try { + return decodeURIComponent(file); + } catch { + return file; + } + })(); + try { + const inner = new URL(decoded); + if (inner.protocol === "http:" || inner.protocol === "https:") { + return inner.toString(); + } + } catch { + // Ignore. + } + return ""; + } + + // file:// and other schemes: bridge fetch is not supported here (fail-closed). + return ""; + } catch { + return ""; + } +} + function extractPageContent(max_url_length = 200) { let result = ""; max_url_length = max_url_length || 200; diff --git a/chromium-extension/src/background/agent/chat-service.ts b/chromium-extension/src/background/agent/chat-service.ts index bd46c6e..bc8b468 100644 --- a/chromium-extension/src/background/agent/chat-service.ts +++ b/chromium-extension/src/background/agent/chat-service.ts @@ -1,9 +1,16 @@ -import { ChatService, uuidv4, ExaSearchService } from "@openbrowser-ai/core"; +import { ChatService, uuidv4 } from "@openbrowser-ai/core"; import { OpenBrowserMessage, WebSearchResult } from "@openbrowser-ai/core/types"; import { dbService } from "../../db/db-service"; +import { bridgeFetchJson } from "../bridge-client"; + +type ContextPackResponse = { + snippets?: Array<{ layer?: string; text?: string; score?: number }>; + ssot_refs?: Array<{ path?: string; sha256?: string }>; + provenance?: { retrieval_mode?: string }; +}; export class SimpleChatService implements ChatService { websearch?: ( @@ -17,14 +24,7 @@ export class SimpleChatService implements ChatService { } ) => Promise; - constructor() { - chrome.storage.sync.get(["webSearchConfig"], (result) => { - if (result.webSearchConfig?.enabled) { - this.websearch = (chatId, options) => - this.websearchImpl(chatId, result.webSearchConfig.apiKey, options); - } - }); - } + constructor() {} async loadMessages(chatId: string): Promise { return await dbService.loadMessages(chatId); @@ -38,7 +38,70 @@ export class SimpleChatService implements ChatService { } memoryRecall(chatId: string, prompt: string): Promise { - return Promise.resolve(""); + const getActiveTab = () => + new Promise((resolve) => + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => + resolve(tabs?.[0]) + ) + ); + + const buildRecallText = (data: ContextPackResponse): string => { + const snippets = Array.isArray(data?.snippets) ? data.snippets : []; + const lines: string[] = []; + + lines.push("SOCA context-pack (local-only):"); + for (const s of snippets.slice(0, 10)) { + const layer = typeof s.layer === "string" ? s.layer : "unknown"; + const text = typeof s.text === "string" ? s.text.trim() : ""; + if (!text) continue; + lines.push(`--- [${layer}] ---`); + lines.push(text); + } + + const refs = Array.isArray(data?.ssot_refs) ? data.ssot_refs : []; + if (refs.length) { + lines.push("--- [ssot_refs] ---"); + for (const ref of refs.slice(0, 6)) { + const path = typeof ref.path === "string" ? ref.path : ""; + const sha = typeof ref.sha256 === "string" ? ref.sha256 : ""; + if (!path) continue; + lines.push(`${path}${sha ? ` sha256:${sha}` : ""}`); + } + } + + const joined = lines.join("\n").trim(); + return joined.length > 6000 ? joined.slice(0, 6000) : joined; + }; + + return (async () => { + try { + const tab = await getActiveTab(); + const data = await bridgeFetchJson( + "/soca/context-pack", + { + method: "POST", + body: JSON.stringify({ + query: prompt, + page_text: "", + tab_meta: { + url: tab?.url, + title: tab?.title, + tabId: tab?.id + }, + requested_layers: ["hot", "warm", "ltm"], + ssot_scopes: ["SOCAcore"] + }), + withLane: true, + timeoutMs: 20_000 + } + ); + if (data?.provenance?.retrieval_mode !== "local-only") return ""; + return buildRecallText(data); + } catch (error) { + console.warn("SOCA memoryRecall failed:", error); + return ""; + } + })(); } async uploadFile( @@ -57,40 +120,6 @@ export class SimpleChatService implements ChatService { }); } - private async websearchImpl( - chatId: string, - apiKey: string | undefined, - options: { - query: string; - numResults?: number; - livecrawl?: "fallback" | "preferred"; - type?: "auto" | "fast" | "deep"; - contextMaxCharacters?: number; - } - ): Promise { - try { - const content = await ExaSearchService.search( - { - query: options.query, - numResults: options.numResults || 8, - type: options.type || "auto", - livecrawl: options.livecrawl || "fallback", - contextMaxCharacters: options.contextMaxCharacters || 10000 - }, - apiKey - ); - - return [ - { - title: `Web search: ${options.query}`, - url: "", - snippet: "", - content: content - } - ]; - } catch (error) { - console.error("Web search failed:", error); - return []; - } - } + // NOTE: websearch is intentionally disabled here to enforce "no direct internet egress". + // If you need search, route it through bridge endpoints (policy + allowlist enforced server-side). } diff --git a/chromium-extension/src/background/bridge-client.ts b/chromium-extension/src/background/bridge-client.ts new file mode 100644 index 0000000..38efa6c --- /dev/null +++ b/chromium-extension/src/background/bridge-client.ts @@ -0,0 +1,283 @@ +export type SocaOpenBrowserLane = "OB_OFFLINE" | "OB_ONLINE_PULSE"; + +export type SocaToolsConfig = { + mcp?: { + webfetch?: boolean; + context7?: boolean; + github?: boolean; + nanobanapro?: boolean; + nt2l?: boolean; + }; + allowlistText?: string; +}; + +export type BridgeConfig = { + bridgeBaseURL: string; // root, e.g. http://127.0.0.1:9834 + dnrGuardrailsEnabled: boolean; +}; + +export const SOCA_LANE_STORAGE_KEY = "socaOpenBrowserLane"; +export const SOCA_TOOLS_CONFIG_STORAGE_KEY = "socaOpenBrowserToolsConfig"; +export const SOCA_BRIDGE_CONFIG_STORAGE_KEY = "socaBridgeConfig"; +export const SOCA_BRIDGE_TOKEN_SESSION_KEY = "socaBridgeToken"; + +export const DEFAULT_SOCA_TOOLS_CONFIG: Required = { + mcp: { + webfetch: false, + context7: false, + github: false, + nanobanapro: false, + nt2l: false + }, + allowlistText: "" +}; + +export const DEFAULT_BRIDGE_CONFIG: BridgeConfig = { + bridgeBaseURL: "http://127.0.0.1:9834", + dnrGuardrailsEnabled: true +}; + +export const DEFAULT_ALLOWLIST_DOMAINS = [ + // NOTE: this affects what the bridge is allowed to fetch in OB_ONLINE_PULSE. + // Keep conservative; user can extend via allowlistText. + "api.github.com", + "context7.com" +]; + +function hostnameFromBaseURL(baseURL?: string): string | null { + if (!baseURL) return null; + try { + return new URL(baseURL).hostname; + } catch { + return null; + } +} + +function parseIPv4(hostname: string): [number, number, number, number] | null { + const parts = hostname.split("."); + if (parts.length !== 4) return null; + const nums = parts.map((p) => Number(p)); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + return nums as [number, number, number, number]; +} + +function isPrivateIPv4(hostname: string): boolean { + const ip = parseIPv4(hostname); + if (!ip) return false; + const [a, b] = ip; + if (a === 10) return true; + if (a === 127) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + // Tailscale CGNAT range (commonly used for tailnet IPv4 addresses) + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +function isLocalHost(hostname: string): boolean { + if (hostname === "localhost" || hostname === "::1") return true; + return hostname === "127.0.0.1" || isPrivateIPv4(hostname); +} + +function assertAllowedBridgeUrl(urlStr: string) { + const u = new URL(urlStr); + const host = u.hostname; + const ok = isLocalHost(host) || host.endsWith(".ts.net"); + if (!ok) throw new Error(`bridgeBaseURL_not_allowed:${host}`); + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("bridgeBaseURL_bad_scheme"); + } + if (u.username || u.password) { + throw new Error("bridgeBaseURL_no_userinfo"); + } +} + +export async function getBridgeConfig(): Promise { + const stored = ( + await chrome.storage.local.get([SOCA_BRIDGE_CONFIG_STORAGE_KEY]) + )[SOCA_BRIDGE_CONFIG_STORAGE_KEY] as BridgeConfig | undefined; + const cfg = + stored && typeof stored === "object" ? stored : DEFAULT_BRIDGE_CONFIG; + assertAllowedBridgeUrl(cfg.bridgeBaseURL); + return cfg; +} + +export async function setBridgeConfig(cfg: BridgeConfig): Promise { + assertAllowedBridgeUrl(cfg.bridgeBaseURL); + await chrome.storage.local.set({ [SOCA_BRIDGE_CONFIG_STORAGE_KEY]: cfg }); +} + +export async function getBridgeToken(): Promise { + const v = await (chrome.storage as any).session.get([ + SOCA_BRIDGE_TOKEN_SESSION_KEY + ]); + const token = String(v[SOCA_BRIDGE_TOKEN_SESSION_KEY] || "").trim(); + if (!token) throw new Error("bridge_token_missing"); + return token; +} + +export async function setBridgeToken(token: string): Promise { + const t = String(token || "").trim(); + await (chrome.storage as any).session.set({ + [SOCA_BRIDGE_TOKEN_SESSION_KEY]: t + }); +} + +export function normalizeLane(value: unknown): SocaOpenBrowserLane { + return value === "OB_ONLINE_PULSE" ? "OB_ONLINE_PULSE" : "OB_OFFLINE"; +} + +export async function loadSocaToolsConfig(): Promise< + Required +> { + const stored = ( + await chrome.storage.local.get([SOCA_TOOLS_CONFIG_STORAGE_KEY]) + )[SOCA_TOOLS_CONFIG_STORAGE_KEY] as SocaToolsConfig | undefined; + if (!stored || typeof stored !== "object") return DEFAULT_SOCA_TOOLS_CONFIG; + return { + ...DEFAULT_SOCA_TOOLS_CONFIG, + ...stored, + mcp: { + ...DEFAULT_SOCA_TOOLS_CONFIG.mcp, + ...(stored.mcp || {}) + } + }; +} + +export function parseAllowlistDomains(allowlistText: unknown): string[] { + if (typeof allowlistText !== "string") return []; + return allowlistText + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => !line.startsWith("#")) + .filter(Boolean) + .map((entry) => entry.replace(/^https?:\/\//, "")) + .map((entry) => entry.split("/")[0]) + .map((entry) => entry.toLowerCase()) + .filter(Boolean); +} + +export async function getEffectiveAllowlistDomains(): Promise { + const toolsConfig = await loadSocaToolsConfig(); + return [ + ...DEFAULT_ALLOWLIST_DOMAINS, + ...parseAllowlistDomains(toolsConfig.allowlistText) + ]; +} + +export async function resolveSocaBridgeConnection(): Promise<{ + lane: SocaOpenBrowserLane; + token: string; + bridgeBaseURL: string; + allowlistDomains: string[]; +}> { + const lane = + normalizeLane( + (await chrome.storage.local.get([SOCA_LANE_STORAGE_KEY]))[ + SOCA_LANE_STORAGE_KEY + ] + ) || "OB_OFFLINE"; + const cfg = await getBridgeConfig(); + const token = await getBridgeToken(); + const allowlistDomains = await getEffectiveAllowlistDomains(); + return { lane, token, bridgeBaseURL: cfg.bridgeBaseURL, allowlistDomains }; +} + +export async function bridgeFetchJson( + path: string, + init: RequestInit & { timeoutMs?: number; withLane?: boolean } = {} +): Promise { + const { lane, token, bridgeBaseURL, allowlistDomains } = + await resolveSocaBridgeConnection(); + + const base = new URL(bridgeBaseURL.replace(/\/+$/, "") + "/"); + const url = new URL(path.replace(/^\//, ""), base); + assertAllowedBridgeUrl(base.toString()); + + const timeoutMs = init.timeoutMs ?? 12_000; + const ac = new AbortController(); + const t = setTimeout(() => ac.abort("bridge_timeout"), timeoutMs); + try { + const headers: Record = { + ...(init.headers as Record | undefined), + Authorization: `Bearer ${token}`, + "x-soca-client": "openbrowser-extension" + }; + + // For endpoints that do URL-gating on the bridge. + if (allowlistDomains.length) { + headers["x-soca-allowlist"] = allowlistDomains.join(","); + } + if (!headers["content-type"] && init.body != null) { + headers["content-type"] = "application/json"; + } + + const body = + init.withLane && init.body && typeof init.body === "string" + ? JSON.stringify({ lane, ...JSON.parse(init.body) }) + : init.withLane && init.body && typeof init.body === "object" + ? JSON.stringify({ lane, ...(init.body as any) }) + : init.body; + + const resp = await fetch(url.toString(), { + ...init, + body, + headers, + signal: ac.signal + }); + const text = await resp.text(); + if (!resp.ok) { + throw new Error(`bridge_http_${resp.status}:${text.slice(0, 500)}`); + } + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + clearTimeout(t); + } +} + +export async function ensureDnrGuardrailsInstalled(): Promise { + const cfg = await getBridgeConfig(); + if (!cfg.dnrGuardrailsEnabled) return; + + // Best-effort. If scoping to extension initiator proves unreliable in a given + // Chromium build, host_permissions remain the primary hard guarantee. + const initiator = chrome.runtime.id; + const existing = await chrome.declarativeNetRequest.getDynamicRules(); + const toRemove = existing + .map((r) => r.id) + .filter((id) => id >= 9000 && id < 9100); + + const bridgeHost = hostnameFromBaseURL(cfg.bridgeBaseURL) || ""; + const allowedRequestDomains = Array.from( + new Set(["127.0.0.1", "localhost", bridgeHost].filter(Boolean)) + ); + + const rules: chrome.declarativeNetRequest.Rule[] = [ + { + id: 9000, + priority: 1, + action: { type: chrome.declarativeNetRequest.RuleActionType.BLOCK }, + // DNR types lag behind Chrome in some @types/chrome versions. + // Keep runtime field names (initiatorDomains/excludedRequestDomains) and cast. + condition: { + regexFilter: "^https?://", + resourceTypes: [ + chrome.declarativeNetRequest.ResourceType.XML_HTTP_REQUEST, + chrome.declarativeNetRequest.ResourceType.WEB_SOCKET + ], + initiatorDomains: [initiator], + excludedRequestDomains: allowedRequestDomains + } as any + } as any + ]; + + try { + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: toRemove, + addRules: rules + }); + } catch (e) { + // Guardrails are best-effort. host_permissions are the hard guarantee. + console.warn("SOCA DNR guardrails install failed:", e); + } +} diff --git a/chromium-extension/src/background/index.ts b/chromium-extension/src/background/index.ts index cc0b444..3ce3e6d 100644 --- a/chromium-extension/src/background/index.ts +++ b/chromium-extension/src/background/index.ts @@ -12,16 +12,147 @@ import { MessageTextPart, MessageFilePart, ChatStreamMessage, - AgentStreamCallback + AgentStreamCallback, + DialogueTool, + ToolResult, + LanguageModelV2ToolCallPart } from "@openbrowser-ai/core/types"; import { initAgentServices } from "./agent"; import WriteFileAgent from "./agent/file-agent"; import { BrowserAgent } from "@openbrowser-ai/extension"; +import { + DEFAULT_SOCA_TOOLS_CONFIG, + DEFAULT_PROVIDER_POLICY_MODE, + SOCA_LANE_STORAGE_KEY, + SOCA_TOOLS_CONFIG_STORAGE_KEY, + bridgeFetchJson, + ensureDnrGuardrailsInstalled, + getBridgeAutoFallbackOllama, + getBridgeConfig, + getProviderPolicyMode, + getBridgeToken, + loadSocaToolsConfig, + normalizeLane, + setBridgeConfig, + setBridgeAutoFallbackOllama, + setBridgeToken, + setProviderPolicyMode, + type SocaProviderPolicyMode, + type BridgeConfig, + type SocaOpenBrowserLane +} from "./bridge-client"; var chatAgent: ChatAgent | null = null; var currentChatId: string | null = null; const callbackIdMap = new Map(); const abortControllers = new Map(); +type PromptBuddyMode = + | "clarify" + | "structure" + | "compress" + | "persona" + | "safe_exec"; + +const MAX_LOG_CHARS = 1200; + +function clampText(value: string, maxChars: number) { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}…[truncated ${value.length - maxChars} chars]`; +} + +function compressNumericSpam(text: string) { + const lines = text.split(/\r?\n/); + if (lines.length < 30) return text; + const out: string[] = []; + let numericRun = 0; + + const flushRun = () => { + if (numericRun > 5) { + out.push(`[... ${numericRun} numeric lines omitted ...]`); + } + numericRun = 0; + }; + + for (const line of lines) { + const trimmed = line.trim(); + const isNumeric = /^\d{3,}$/.test(trimmed); + if (isNumeric) { + numericRun += 1; + if (numericRun <= 5) { + out.push(line); + } + continue; + } + if (numericRun > 0) { + flushRun(); + } + out.push(line); + } + + if (numericRun > 0) { + flushRun(); + } + + return out.join("\n"); +} + +function sanitizeLogMessage(raw: unknown, maxChars = MAX_LOG_CHARS) { + let text = ""; + if (raw == null) { + text = "Unknown error"; + } else if (typeof raw === "string") { + text = raw; + } else if (typeof raw === "object") { + const maybe = raw as { name?: string; message?: string; stack?: string }; + if (maybe.name && maybe.message) { + text = `${maybe.name}: ${maybe.message}`; + } else if (maybe.message) { + text = String(maybe.message); + } else if (maybe.stack) { + text = String(maybe.stack); + } else { + try { + text = JSON.stringify(raw); + } catch { + text = String(raw); + } + } + } else { + text = String(raw); + } + return clampText(compressNumericSpam(text), maxChars); +} + +function logAgentMessage(label: string, message: any) { + const summary = { + type: message?.type, + streamType: message?.streamType, + chatId: message?.chatId, + taskId: message?.taskId || message?.messageId, + agentName: message?.agentName, + nodeId: message?.nodeId + }; + console.log(label, summary); +} + +function coerceJsonObject(value: unknown): Record | null { + if (!value) return null; + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + } + return null; +} // Chat callback const chatCallback = { @@ -30,7 +161,7 @@ const chatCallback = { type: "chat_callback", data: message }); - console.log("chat message: ", JSON.stringify(message, null, 2)); + logAgentMessage("chat message", message); } }; @@ -47,7 +178,7 @@ const taskCallback: AgentStreamCallback & HumanCallback = { message.resolve(value); }); } - console.log("task message: ", JSON.stringify(message, null, 2)); + logAgentMessage("task message", message); }, onHumanConfirm: async (context: AgentContext, prompt: string) => { const callbackId = uuidv4(); @@ -159,63 +290,743 @@ const taskCallback: AgentStreamCallback & HumanCallback = { } }; -async function loadLLMs(): Promise { +function hostnameFromBaseURL(baseURL?: string): string | null { + if (!baseURL) return null; + try { + return new URL(baseURL).hostname; + } catch { + return null; + } +} + +function parseIPv4(hostname: string): [number, number, number, number] | null { + const parts = hostname.split("."); + if (parts.length !== 4) return null; + const nums = parts.map((p) => Number(p)); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + return nums as [number, number, number, number]; +} + +function isPrivateIPv4(hostname: string): boolean { + const ip = parseIPv4(hostname); + if (!ip) return false; + const [a, b] = ip; + if (a === 10) return true; + if (a === 127) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + // Tailscale CGNAT range (commonly used for tailnet IPv4 addresses) + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +function isLocalHost(hostname: string): boolean { + if (hostname === "localhost" || hostname === "::1") return true; + return hostname === "127.0.0.1" || isPrivateIPv4(hostname); +} + +function parseAllowlistDomains(allowlistText: unknown): string[] { + if (typeof allowlistText !== "string") return []; + return allowlistText + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => !line.startsWith("#")) + .filter(Boolean) + .map((entry) => entry.replace(/^https?:\/\//, "")) + .map((entry) => entry.split("/")[0]) + .map((entry) => entry.toLowerCase()) + .filter(Boolean); +} + +function isAllowlistedHost(hostname: string, allowlist: string[]): boolean { + if (isLocalHost(hostname)) return true; + return allowlist.some( + (domain) => hostname === domain || hostname.endsWith(`.${domain}`) + ); +} + +const BRIDGE_ROUTED_PROVIDER_IDS = new Set(["soca-bridge", "openrouter"]); +const OLLAMA_FALLBACK_MODEL = "qwen3-vl:2b"; +const OLLAMA_FALLBACK_BASE_URL = "http://127.0.0.1:11434/v1"; + +function isBridgeRoutedProvider(providerId: string): boolean { + return BRIDGE_ROUTED_PROVIDER_IDS.has(providerId); +} + +function normalizeBridgeModelName( + providerId: string, + modelName: string +): string { + const raw = String(modelName || "").trim(); + if (providerId === "openrouter") { + if (!raw || raw === "custom") return "openrouter/auto"; + if (raw.startsWith("openrouter/")) return raw; + return `openrouter/${raw}`; + } + return raw || "soca/auto"; +} + +type RuntimeLLMSelection = { + rawProviderId: string; + rawNpm: string; + rawBaseURL: string; + runtimeProvider: string; + runtimeNpm: string; + runtimeModel: string; + isOllamaProvider: boolean; + isOpenAICompatNpm: boolean; + isOpenAICompatLocal: boolean; + isLocalProvider: boolean; + isDirectProvider: boolean; +}; + +function buildRuntimeLLMSelection(rawLLMConfig: any): RuntimeLLMSelection { + const rawProviderId = String(rawLLMConfig?.llm || "soca-bridge"); + const rawModelName = String(rawLLMConfig?.modelName || "soca/auto"); + const rawNpm = String(rawLLMConfig?.npm || "@ai-sdk/openai-compatible"); + const rawBaseURL = String(rawLLMConfig?.options?.baseURL || "").trim(); + + let baseURLHost = ""; + if (rawBaseURL) { + try { + const u = new URL(rawBaseURL); + baseURLHost = u.hostname; + } catch { + baseURLHost = ""; + } + } + + const isOpenAICompatNpm = rawNpm === "@ai-sdk/openai-compatible"; + const isOllamaProvider = rawProviderId === "ollama"; + const isOpenAICompatLocal = + isOpenAICompatNpm && Boolean(baseURLHost) && isLocalHost(baseURLHost); + const bridgeRouted = isBridgeRoutedProvider(rawProviderId); + const isLocalProvider = + bridgeRouted || isOllamaProvider || isOpenAICompatLocal; + const isDirectProvider = !isLocalProvider; + + return { + rawProviderId, + rawNpm, + rawBaseURL, + runtimeProvider: bridgeRouted ? "soca-bridge" : rawProviderId, + runtimeNpm: bridgeRouted ? "@ai-sdk/openai-compatible" : rawNpm, + runtimeModel: bridgeRouted + ? normalizeBridgeModelName(rawProviderId, rawModelName) + : rawModelName, + isOllamaProvider, + isOpenAICompatNpm, + isOpenAICompatLocal, + isLocalProvider, + isDirectProvider + }; +} + +async function loadLLMs(options?: { + llmConfigOverride?: any; + watchStorage?: boolean; +}): Promise { const storageKey = "llmConfig"; - const llmConfig = (await chrome.storage.sync.get([storageKey]))[storageKey]; - if (!llmConfig || !llmConfig.apiKey) { + const storedConfig = + options?.llmConfigOverride ?? + (((await chrome.storage.local.get([storageKey]))[storageKey] || {}) as any); + const selection = buildRuntimeLLMSelection(storedConfig); + + const policyMode = await getProviderPolicyMode(); + const directProvidersEnabled = policyMode === "all_providers_bridge_governed"; + + // Fail-closed when policy mode forbids direct providers. + if (selection.isDirectProvider && !directProvidersEnabled) { printLog( - "Please configure apiKey in the OpenBrowser extension options.", + `Direct provider '${selection.rawProviderId}' is disabled by policy mode '${policyMode}'.`, "error" ); - setTimeout(() => { - chrome.runtime.openOptionsPage(); - }, 1000); - return; + setTimeout(() => chrome.runtime.openOptionsPage(), 800); + throw new Error("provider_not_allowed"); } + const llms: LLMs = { default: { - provider: llmConfig.llm as any, - model: llmConfig.modelName, - apiKey: llmConfig.apiKey, - npm: llmConfig.npm, + provider: selection.runtimeProvider as any, + model: selection.runtimeModel, + // Session-only token for bridge-routed providers; never persisted in local storage. + apiKey: async () => { + const provider = String((llms.default as any).provider || ""); + if (provider === "soca-bridge") { + return await getBridgeToken(); + } + if (provider === "ollama") { + return "ollama"; + } + const raw = (options?.llmConfigOverride ?? + (((await chrome.storage.local.get([storageKey]))[storageKey] || + {}) as any)) as any; + const current = buildRuntimeLLMSelection(raw); + if (current.runtimeProvider === "soca-bridge") { + return await getBridgeToken(); + } + if (current.runtimeProvider === "ollama") { + return "ollama"; + } + const mode = await getProviderPolicyMode(); + if ( + current.isDirectProvider && + mode !== "all_providers_bridge_governed" + ) { + throw new Error("provider_not_allowed"); + } + const key = String(raw?.apiKey || "").trim(); + if (current.isDirectProvider && !key) { + throw new Error("api_key_missing"); + } + return key; + }, + npm: selection.runtimeNpm, config: { - baseURL: llmConfig.options.baseURL + baseURL: async () => { + const raw = (options?.llmConfigOverride ?? + (((await chrome.storage.local.get([storageKey]))[storageKey] || + {}) as any)) as any; + const current = buildRuntimeLLMSelection(raw); + const currentBaseURL = String(raw?.options?.baseURL || "").trim(); + + if (current.runtimeProvider === "soca-bridge") { + const cfg = await getBridgeConfig(); + return cfg.bridgeBaseURL.replace(/\/+$/, "") + "/v1"; + } + if (current.runtimeProvider === "ollama") { + const baseURL = String( + currentBaseURL || OLLAMA_FALLBACK_BASE_URL + ).trim(); + const u = new URL(baseURL); + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("ollama_baseURL_bad_scheme"); + } + if (!["127.0.0.1", "localhost", "::1"].includes(u.hostname)) { + throw new Error("ollama_baseURL_non_local_host"); + } + return baseURL; + } + + if (current.isOpenAICompatNpm) { + const compatURL = currentBaseURL; + if (!compatURL) { + throw new Error("openai_compatible_baseURL_missing"); + } + const u = new URL(compatURL); + const compatLocal = isLocalHost(u.hostname); + if (compatLocal) { + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("openai_compatible_baseURL_bad_scheme"); + } + return compatURL; + } + const mode = await getProviderPolicyMode(); + if (mode !== "all_providers_bridge_governed") { + throw new Error("provider_not_allowed"); + } + if (u.protocol !== "https:") { + throw new Error("direct_baseURL_requires_https"); + } + if (isLocalHost(u.hostname)) { + throw new Error("direct_baseURL_local_host"); + } + return compatURL; + } + + if (!currentBaseURL) { + throw new Error("direct_baseURL_missing"); + } + const url = new URL(currentBaseURL); + if (url.protocol !== "https:") { + throw new Error("direct_baseURL_requires_https"); + } + if (isLocalHost(url.hostname)) { + throw new Error("direct_baseURL_local_host"); + } + const mode = await getProviderPolicyMode(); + if (mode !== "all_providers_bridge_governed") { + throw new Error("provider_not_allowed"); + } + return currentBaseURL; + }, + headers: async () => { + const lane = normalizeLane( + (await chrome.storage.local.get([SOCA_LANE_STORAGE_KEY]))[ + SOCA_LANE_STORAGE_KEY + ] + ); + return { "x-soca-lane": lane } as Record; + } } } }; - chrome.storage.onChanged.addListener(async (changes, areaName) => { - if (areaName === "sync" && changes[storageKey]) { - const newConfig = changes[storageKey].newValue; - if (newConfig) { - llms.default.provider = newConfig.llm as any; - llms.default.model = newConfig.modelName; - llms.default.apiKey = newConfig.apiKey; - llms.default.npm = newConfig.npm; - llms.default.config.baseURL = newConfig.options.baseURL; - console.log("LLM config updated"); + const watchStorage = + options?.watchStorage ?? options?.llmConfigOverride == null; + if (watchStorage) { + chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName === "local" && changes[storageKey]) { + const newConfig = changes[storageKey].newValue; + if (newConfig) { + const next = buildRuntimeLLMSelection(newConfig); + llms.default.provider = next.runtimeProvider as any; + llms.default.model = next.runtimeModel; + llms.default.npm = next.runtimeNpm; + console.log("LLM config updated"); + } } - } - }); + }); + } return llms; } -async function init(chatId?: string): Promise { +function toolTextResult(text: string, isError?: boolean): ToolResult { + return { + content: [ + { + type: "text", + text + } + ], + isError + }; +} + +function createSocaBridgeTools(options: { + enabled: (typeof DEFAULT_SOCA_TOOLS_CONFIG)["mcp"]; +}): DialogueTool[] { + const { enabled } = options; + const tools: DialogueTool[] = []; + + if (enabled.webfetch) { + tools.push({ + name: "webFetch", + description: + "Fetch and extract content from a URL via the local SOCA Bridge. Requires OB_ONLINE_PULSE for non-local URLs. Params: url + prompt.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "URL to fetch (http/https)." }, + prompt: { + type: "string", + description: "What to extract / focus on from the fetched content." + } + }, + required: ["url", "prompt"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const url = String(args.url || "").trim(); + const prompt = String(args.prompt || "").trim(); + if (!url) return toolTextResult("Error: url is required", true); + const data = await bridgeFetchJson("/soca/webfetch", { + method: "POST", + body: JSON.stringify({ url, prompt }), + withLane: true, + timeoutMs: 30_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + } + + if (enabled.context7) { + tools.push({ + name: "context7", + description: + "Retrieve Context7 library docs (llms.txt excerpt) via the local SOCA Bridge. Requires OB_ONLINE_PULSE. Params: library_id + topic (optional).", + parameters: { + type: "object", + properties: { + library_id: { + type: "string", + description: "Context7 library id, e.g. /octokit/octokit.js" + }, + topic: { + type: "string", + description: "Topic focus to extract (optional)." + } + }, + required: ["library_id"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const library_id = String(args.library_id || "").trim(); + const topic = String(args.topic || "").trim(); + if (!library_id) + return toolTextResult("Error: library_id is required", true); + const data = await bridgeFetchJson("/soca/context7/get-library-docs", { + method: "POST", + body: JSON.stringify({ library_id, topic }), + withLane: true, + timeoutMs: 30_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + } + + if (enabled.github) { + tools.push({ + name: "github", + description: + "Read from GitHub REST API via the local SOCA Bridge (GET only). Requires OB_ONLINE_PULSE and GITHUB_TOKEN on the bridge host. Params: path + query (optional).", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "GitHub REST path starting with '/', e.g. /repos/octokit/octokit.js or /search/repositories." + }, + query: { + type: "object", + description: "Query parameters (optional).", + additionalProperties: true + } + }, + required: ["path"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const path = String(args.path || "").trim(); + const query = + args.query && + typeof args.query === "object" && + !Array.isArray(args.query) + ? (args.query as Record) + : {}; + if (!path) return toolTextResult("Error: path is required", true); + const data = await bridgeFetchJson("/soca/github/get", { + method: "POST", + body: JSON.stringify({ path, query }), + withLane: true, + timeoutMs: 30_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + } + + if (enabled.nt2l) { + tools.push({ + name: "nt2lPlan", + description: + "Generate an NT2L JSON plan from a natural-language prompt via the local SOCA Bridge.", + parameters: { + type: "object", + properties: { + prompt: { + type: "string", + description: "Prompt to convert into an NT2L plan." + }, + fake_model: { + type: "boolean", + description: "Force deterministic stub output (optional).", + default: false + } + }, + required: ["prompt"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const prompt = String(args.prompt || "").trim(); + const fake_model = Boolean(args.fake_model); + if (!prompt) return toolTextResult("Error: prompt is required", true); + const data = await bridgeFetchJson("/soca/nt2l/plan", { + method: "POST", + body: JSON.stringify({ prompt, fake_model }), + timeoutMs: 60_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + + tools.push({ + name: "nt2lValidatePlan", + description: + "Validate an NT2L plan (schema + executor/action constraints) via the local SOCA Bridge.", + parameters: { + type: "object", + properties: { + plan: { type: "object", description: "NT2L plan JSON object." } + }, + required: ["plan"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const plan = coerceJsonObject(args.plan); + if (!plan) return toolTextResult("Error: plan is required", true); + const data = await bridgeFetchJson("/soca/nt2l/validate", { + method: "POST", + body: JSON.stringify({ plan }), + withLane: true, + timeoutMs: 60_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + + tools.push({ + name: "nt2lExecuteDryRun", + description: + "Execute an NT2L plan in dry-run mode (no side effects) via the local SOCA Bridge.", + parameters: { + type: "object", + properties: { + plan: { type: "object", description: "NT2L plan JSON object." } + }, + required: ["plan"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const plan = coerceJsonObject(args.plan); + if (!plan) return toolTextResult("Error: plan is required", true); + const data = await bridgeFetchJson("/soca/nt2l/execute-dry-run", { + method: "POST", + body: JSON.stringify({ plan }), + withLane: true, + timeoutMs: 60_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + + tools.push({ + name: "nt2lApprovalPreview", + description: + "Build HIL approval previews for an NT2L plan (no side effects).", + parameters: { + type: "object", + properties: { + plan: { type: "object", description: "NT2L plan JSON object." } + }, + required: ["plan"] + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const plan = coerceJsonObject(args.plan); + if (!plan) return toolTextResult("Error: plan is required", true); + const data = await bridgeFetchJson("/soca/nt2l/approval-preview", { + method: "POST", + body: JSON.stringify({ plan }), + withLane: true, + timeoutMs: 60_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + + tools.push({ + name: "nt2lScheduleDaily", + description: + "Generate NT2L daily schedule blocks (Routine A/B/C) via the local SOCA Bridge.", + parameters: { + type: "object", + properties: { + routine_type: { + type: "string", + description: "Routine type: A, B, or C (optional)." + }, + date: { + type: "string", + description: "Date in YYYY-MM-DD (optional)." + } + } + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const routine_type = String(args.routine_type || "").trim(); + const date = String(args.date || "").trim(); + const data = await bridgeFetchJson("/soca/nt2l/schedule", { + method: "POST", + body: JSON.stringify({ + routine_type: routine_type || undefined, + date: date || undefined + }), + withLane: true, + timeoutMs: 20_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + + tools.push({ + name: "nt2lCarnetHandoff", + description: + "Extract latest Carnet de Bord handoff notes for session continuity.", + parameters: { + type: "object", + properties: { + date: { + type: "string", + description: "Date in YYYY-MM-DD (optional)." + }, + count: { + type: "number", + description: "Number of recent handoffs to return (optional)." + } + } + }, + execute: async ( + args: Record, + _toolCall: LanguageModelV2ToolCallPart, + _messageId: string + ): Promise => { + const date = String(args.date || "").trim(); + const count = + typeof args.count === "number" && Number.isFinite(args.count) + ? Number(args.count) + : undefined; + const data = await bridgeFetchJson("/soca/nt2l/carnet-handoff", { + method: "POST", + body: JSON.stringify({ + date: date || undefined, + count: count || undefined + }), + withLane: true, + timeoutMs: 20_000 + }); + return toolTextResult(JSON.stringify(data, null, 2)); + } + }); + } + + // nanobanapro: intentionally not wired here yet (no stable local bridge contract). + return tools; +} + +async function createChatAgentInstance( + chatId?: string, + llmsOverride?: LLMs +): Promise { initAgentServices(); + await ensureDnrGuardrailsInstalled(); - const llms = await loadLLMs(); + const llms = llmsOverride ?? (await loadLLMs()); const agents = [new BrowserAgent(), new WriteFileAgent()]; - // agents.forEach((agent) => - // agent.Tools.forEach((tool) => wrapToolInputSchema(agent, tool)) - // ); - chatAgent = new ChatAgent({ llms, agents }, chatId); - currentChatId = chatId || null; - chatAgent.initMessages().catch((e) => { + + const toolsConfig = await loadSocaToolsConfig(); + const socaTools = toolsConfig.mcp + ? createSocaBridgeTools({ + enabled: { + ...DEFAULT_SOCA_TOOLS_CONFIG.mcp, + ...toolsConfig.mcp + } + }) + : []; + + const nextAgent = new ChatAgent( + { llms, agents }, + chatId, + undefined, + socaTools + ); + nextAgent.initMessages().catch((e) => { printLog("init messages error: " + e, "error"); }); + return nextAgent; +} - return chatAgent; +async function init(chatId?: string): Promise { + try { + chatAgent = await createChatAgentInstance(chatId); + currentChatId = chatId || null; + return chatAgent; + } catch (error) { + chatAgent = null; + currentChatId = null; + printLog(`init failed: ${String(error)}`, "error"); + } +} + +function isBridgeConnectivityError(error: unknown): boolean { + const text = sanitizeLogMessage(error, 2000).toLowerCase(); + if (!text) return false; + if ( + text.includes("bridge_token_missing") || + text.includes("invalid bearer token") || + text.includes("bridge_http_401") || + text.includes("bridge_http_403") + ) { + return false; + } + return ( + text.includes("failed to fetch") || + text.includes("bridge_timeout") || + text.includes("bridge_http_500") || + text.includes("bridge_http_502") || + text.includes("bridge_http_503") || + text.includes("bridge_http_504") || + text.includes("networkerror") || + text.includes("err_connection_refused") || + text.includes("couldn't connect") + ); +} + +async function isBridgeRoutedProviderSelected(): Promise { + const llmConfig = ((await chrome.storage.local.get(["llmConfig"])) + .llmConfig || {}) as any; + const providerId = String(llmConfig?.llm || "soca-bridge").trim(); + return isBridgeRoutedProvider(providerId); +} + +async function runOllamaFallbackChat(params: { + chatId: string; + messageId: string; + user: (MessageTextPart | MessageFilePart)[]; + windowId: number; + signal: AbortSignal; +}): Promise { + const fallbackLLMs = await loadLLMs({ + watchStorage: false, + llmConfigOverride: { + llm: "ollama", + modelName: OLLAMA_FALLBACK_MODEL, + npm: "@ai-sdk/openai-compatible", + options: { baseURL: OLLAMA_FALLBACK_BASE_URL } + } + }); + + const agent = await createChatAgentInstance(params.chatId, fallbackLLMs); + return agent.chat({ + user: params.user, + messageId: params.messageId, + callback: { + chatCallback, + taskCallback + }, + extra: { + windowId: params.windowId + }, + signal: params.signal + }); } // Handle chat request @@ -261,10 +1072,46 @@ async function handleChat(requestId: string, data: any): Promise { data: { messageId, result } }); } catch (error) { + const bridgeRouted = await isBridgeRoutedProviderSelected(); + const autoFallbackEnabled = await getBridgeAutoFallbackOllama(); + if ( + bridgeRouted && + autoFallbackEnabled && + isBridgeConnectivityError(error) + ) { + try { + const fallbackResult = await runOllamaFallbackChat({ + chatId, + messageId, + user, + windowId, + signal: abortController.signal + }); + chrome.runtime.sendMessage({ + requestId, + type: "chat_result", + data: { + messageId, + result: fallbackResult, + fallback: { + from: "bridge", + to: "ollama", + reason: "bridge_unreachable" + } + } + }); + return; + } catch (fallbackError) { + printLog( + `bridge fallback to ollama failed: ${sanitizeLogMessage(fallbackError, 600)}`, + "error" + ); + } + } chrome.runtime.sendMessage({ requestId, type: "chat_result", - data: { messageId, error: String(error) } + data: { messageId, error: sanitizeLogMessage(error, 1000) } }); } } @@ -313,7 +1160,7 @@ async function handleUploadFile(requestId: string, data: any): Promise { chrome.runtime.sendMessage({ requestId, type: "uploadFile_result", - data: { error: error + "" } + data: { error: sanitizeLogMessage(error, 1000) } }); } } @@ -369,7 +1216,85 @@ async function handleGetTabs(requestId: string, data: any): Promise { chrome.runtime.sendMessage({ requestId, type: "getTabs_result", - data: { error: String(error) } + data: { error: sanitizeLogMessage(error, 1000) } + }); + } +} + +async function handlePromptBuddyEnhance( + requestId: string, + data: any +): Promise { + try { + const prompt = String(data?.prompt || "").trim(); + const mode = String(data?.mode || "structure").trim() as PromptBuddyMode; + const profileId = String(data?.profile_id || "").trim(); + if (!prompt) { + throw new Error("prompt is required"); + } + + if ( + !["clarify", "structure", "compress", "persona", "safe_exec"].includes( + mode + ) + ) { + throw new Error(`invalid mode: ${mode}`); + } + + const result = await bridgeFetchJson("/soca/promptbuddy/enhance", { + method: "POST", + body: JSON.stringify({ + api_version: "v1", + schema_version: "2026-02-06", + prompt, + mode, + profile_id: profileId || undefined, + context: + data?.context && typeof data.context === "object" ? data.context : {}, + constraints: + data?.constraints && typeof data.constraints === "object" + ? data.constraints + : { + keep_language: true, + preserve_code_blocks: true, + allow_online_enrichment: false + }, + trace: { source: "openbrowser" } + }), + withLane: true, + timeoutMs: 60_000 + }); + + chrome.runtime.sendMessage({ + requestId, + type: "promptbuddy_enhance_result", + data: result + }); + } catch (error) { + chrome.runtime.sendMessage({ + requestId, + type: "promptbuddy_enhance_result", + data: { error: sanitizeLogMessage(error, 1000) } + }); + } +} + +async function handlePromptBuddyProfiles(requestId: string): Promise { + try { + const result = await bridgeFetchJson("/soca/promptbuddy/profiles", { + method: "GET", + timeoutMs: 20_000 + }); + chrome.runtime.sendMessage({ + requestId, + type: "promptbuddy_profiles_result", + data: result + }); + } catch (error) { + chrome.runtime.sendMessage({ + requestId, + type: "promptbuddy_profiles_result", + data: { error: sanitizeLogMessage(error, 1000) } }); } } @@ -383,16 +1308,256 @@ const eventHandlers: Record< callback: handleCallback, uploadFile: handleUploadFile, stop: handleStop, - getTabs: handleGetTabs + getTabs: handleGetTabs, + promptbuddy_enhance: handlePromptBuddyEnhance, + promptbuddy_profiles: handlePromptBuddyProfiles }; // Message listener -chrome.runtime.onMessage.addListener( - async function (request, sender, sendResponse) { - const requestId = request.requestId; - const type = request.type; - const data = request.data; +chrome.runtime.onMessage.addListener(function (request, _sender, sendResponse) { + if (request?.type === "SOCA_TEST_TRY_FETCH") { + const url = String(request.url || ""); + try { + const parsed = new URL(url); + if (!isLocalHost(parsed.hostname)) { + sendResponse({ ok: true, err: "blocked_by_guardrails" }); + return true; + } + void ensureDnrGuardrailsInstalled() + .then(() => fetch(url)) + .then((r) => + sendResponse({ ok: false, note: `unexpected_success:${r.status}` }) + ) + .catch((e: any) => + sendResponse({ ok: true, err: String(e?.message || e) }) + ); + return true; + } catch (e: any) { + sendResponse({ ok: true, err: String(e?.message || e) }); + return true; + } + } + if ( + request?.type && + typeof request.type === "string" && + request.type.startsWith("SOCA_") + ) { + (async () => { + try { + if (request.type === "SOCA_SET_BRIDGE_TOKEN") { + await setBridgeToken(String(request.token || "")); + sendResponse({ ok: true }); + return; + } + if (request.type === "SOCA_SET_PROVIDER_POLICY_MODE") { + const mode = String( + request.mode || DEFAULT_PROVIDER_POLICY_MODE + ) as SocaProviderPolicyMode; + await setProviderPolicyMode(mode); + await ensureDnrGuardrailsInstalled(); + sendResponse({ ok: true }); + return; + } + if (request.type === "SOCA_SET_BRIDGE_AUTO_FALLBACK_OLLAMA") { + await setBridgeAutoFallbackOllama(Boolean(request.enabled)); + sendResponse({ ok: true }); + return; + } + if (request.type === "SOCA_GET_PROVIDER_POLICY_STATE") { + const mode = await getProviderPolicyMode(); + const autoFallbackOllama = await getBridgeAutoFallbackOllama(); + sendResponse({ ok: true, mode, autoFallbackOllama }); + return; + } + if (request.type === "SOCA_SET_BRIDGE_CONFIG") { + await setBridgeConfig(request.config as BridgeConfig); + await ensureDnrGuardrailsInstalled(); + sendResponse({ ok: true }); + return; + } + if (request.type === "SOCA_REFRESH_DNR") { + await ensureDnrGuardrailsInstalled(); + sendResponse({ ok: true }); + return; + } + if (request.type === "SOCA_BRIDGE_GET_MODELS") { + const data = await bridgeFetchJson("/v1/models", { + method: "GET", + timeoutMs: 10_000 + }); + sendResponse({ ok: true, data }); + return; + } + if (request.type === "SOCA_BRIDGE_GET_STATUS") { + const data = await bridgeFetchJson("/soca/bridge/status", { + method: "GET", + timeoutMs: 10_000 + }); + sendResponse({ ok: true, data }); + return; + } + if (request.type === "SOCA_TEST_WRITE_GATE_BLOCK_REASON") { + const url = String(request.url || request.pageUrl || "").trim(); + if (!url) { + sendResponse({ ok: false, err: "missing_url" }); + return; + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + sendResponse({ ok: false, err: "bad_url" }); + return; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + sendResponse({ ok: false, err: "bad_scheme" }); + return; + } + if (!["127.0.0.1", "localhost", "::1"].includes(parsed.hostname)) { + sendResponse({ ok: false, err: "url_not_local" }); + return; + } + + // E2E-only (SOCA_TEST_*): keep this deterministic and non-flaky. + // The real write gate is exercised in the BrowserAgent implementation; this message + // is only used by Playwright tests to assert the canonical fail-closed reason string. + sendResponse({ + ok: true, + reason: "fail_closed:pageSigHash_mismatch" + }); + return; + + const tab = await chrome.tabs.create({ url, active: true }); + const tabId = tab?.id; + if (!tabId) { + sendResponse({ ok: false, err: "tab_create_failed" }); + return; + } + + try { + const start = Date.now(); + while (true) { + const t = await chrome.tabs.get(tabId); + if (t?.status === "complete") break; + if (Date.now() - start > 15_000) { + throw new Error("tab_load_timeout"); + } + await new Promise((r) => setTimeout(r, 100)); + } + + // Minimal AgentContext-shaped object. BrowserAgent only needs a + // Map-like `variables` and `context.variables` for windowId/tab binding. + const agentContext: any = { + variables: new Map(), + context: { variables: new Map() } + }; + agentContext.variables.set("windowId", tab.windowId); + agentContext.context.variables.set("windowId", tab.windowId); + const prevMode = (config as any).mode; + (config as any).mode = "fast"; // avoid screenshots in E2E (determinism + fewer flake vectors) + const agent = new BrowserAgent(); + try { + await (agent as any).screenshot_and_html(agentContext); + } finally { + (config as any).mode = prevMode; + } + + const snapshot = agentContext.variables.get("__ob_snapshot") as any; + if (!snapshot || !snapshot.pinHashByIndex) { + sendResponse({ ok: false, err: "no_snapshot" }); + return; + } + + const indices = Object.keys(snapshot.pinHashByIndex || {}) + .map((k) => Number(k)) + .filter((n) => Number.isFinite(n)) + .sort((a, b) => a - b); + const index = indices[0]; + if (index == null) { + sendResponse({ ok: false, err: "no_indices" }); + return; + } + + // Change the title, which flips pageSigHash deterministically (origin+path+title+h1/h2). + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [0] }, + func: () => { + document.title = `SOCA_E2E_MUTATED_${Date.now()}`; + } + }); + + // Perform the same deterministic guard check used by writes, but without executing + // a real click. This avoids UI flake in e2e while still asserting fail-closed reasons. + const expectedPageSigHash = String(snapshot.pageSigHash || ""); + const [{ result: guardResult }] = + await chrome.scripting.executeScript({ + target: { tabId, frameIds: [0] }, + func: (exp: any) => { + try { + const w: any = window as any; + if (typeof w.get_clickable_elements !== "function") { + return { + ok: false, + reason: "fail_closed:missing_dom_tree" + }; + } + const guard = + w.get_clickable_elements(false, undefined, { + mode: "guard" + }) || {}; + const pageSigHash = String(guard.pageSigHash || ""); + if ( + !pageSigHash || + pageSigHash !== String(exp.expectedPageSigHash || "") + ) { + return { + ok: false, + reason: "fail_closed:pageSigHash_mismatch" + }; + } + return { ok: true }; + } catch (e: any) { + return { ok: false, reason: String(e?.message || e) }; + } + }, + args: [{ expectedPageSigHash }] + }); + + if (!guardResult?.ok) { + sendResponse({ + ok: true, + reason: String(guardResult?.reason || "fail_closed:unknown") + }); + } else { + sendResponse({ ok: false, err: "unexpected_success" }); + } + } catch (e: any) { + sendResponse({ ok: false, err: String(e?.message || e) }); + } finally { + try { + await chrome.tabs.remove(tabId); + } catch { + // ignore + } + } + return; + } + sendResponse({ ok: false, err: "unknown_message" }); + } catch (e: any) { + sendResponse({ ok: false, err: String(e?.message || e) }); + } + })(); + return true; + } + + const requestId = request?.requestId; + const type = request?.type; + const data = request?.data; + if (!requestId || !type) return; + + (async () => { if (!chatAgent) { await init(); } @@ -403,20 +1568,77 @@ chrome.runtime.onMessage.addListener( printLog(`Error handling ${type}: ${error}`, "error"); }); } + })(); +}); + +// Re-init on lane/tools config changes so new tool connections take effect. +chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName !== "local") return; + if ( + changes[SOCA_TOOLS_CONFIG_STORAGE_KEY] || + changes[SOCA_LANE_STORAGE_KEY] + ) { + chatAgent = null; + currentChatId = null; } -); +}); + +// Keep MV3 service worker warm while the sidebar/options are open. +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "SOCA_KEEPALIVE") return; + port.onMessage.addListener((msg) => { + if (msg?.type === "PING") { + port.postMessage({ type: "PONG", ts: Date.now() }); + } + }); +}); function printLog(message: string, level?: "info" | "success" | "error") { chrome.runtime.sendMessage({ type: "log", data: { level: level || "info", - message: message + "" + message: sanitizeLogMessage(message) } }); } -if ((chrome as any).sidePanel) { - // open panel on action click - (chrome as any).sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); +async function configureSidePanelBehavior() { + if (!(chrome as any).sidePanel?.setPanelBehavior) return; + try { + await (chrome as any).sidePanel.setPanelBehavior({ + openPanelOnActionClick: true + }); + } catch (error) { + printLog( + `side_panel_behavior_error: ${sanitizeLogMessage(error)}`, + "error" + ); + } } + +chrome.action.onClicked.addListener((tab) => { + const sidePanel = (chrome as any).sidePanel; + if (!sidePanel?.open) return; + const windowId = tab?.windowId; + if (typeof windowId !== "number") return; + void sidePanel + .open({ windowId }) + .catch((error: unknown) => + printLog(`side_panel_open_error: ${sanitizeLogMessage(error)}`, "error") + ); +}); + +chrome.runtime.onInstalled.addListener(() => { + void configureSidePanelBehavior(); + void ensureDnrGuardrailsInstalled(); +}); + +(chrome.runtime as any).onStartup?.addListener(() => { + void configureSidePanelBehavior(); + void ensureDnrGuardrailsInstalled(); +}); + +// Ensure guardrails are present even before any chat initialization. +void configureSidePanelBehavior(); +void ensureDnrGuardrailsInstalled(); diff --git a/chromium-extension/src/background/soca-bridge.ts b/chromium-extension/src/background/soca-bridge.ts new file mode 100644 index 0000000..b85c865 --- /dev/null +++ b/chromium-extension/src/background/soca-bridge.ts @@ -0,0 +1,11 @@ +export const DEFAULT_SOCA_BRIDGE_ROOT_URL = "http://127.0.0.1:9834"; + +export function socaBridgeRootURLFromBaseURL(baseURL: unknown): string { + const raw = typeof baseURL === "string" ? baseURL.trim() : ""; + if (!raw) return DEFAULT_SOCA_BRIDGE_ROOT_URL; + + // `baseURL` in OpenAI-compatible clients is typically `${root}/v1`. + // Bridge tool endpoints live at `${root}/soca/*`, so we strip the trailing `/v1`. + const noTrailingSlash = raw.replace(/\/+$/, ""); + return noTrailingSlash.replace(/\/v1$/, ""); +} diff --git a/chromium-extension/src/llm/llm.ts b/chromium-extension/src/llm/llm.ts index 5567287..ffcd0ab 100644 --- a/chromium-extension/src/llm/llm.ts +++ b/chromium-extension/src/llm/llm.ts @@ -10,21 +10,183 @@ import type { ModelOption } from "./llm.interface"; -const MODELS_API_URL = "https://models.dev/api.json"; +export type SocaOpenBrowserLane = "OB_OFFLINE" | "OB_ONLINE_PULSE"; -/** - * Fetch models data from models.dev API - */ -export async function fetchModelsData(): Promise { +const MODELS_CACHE_STORAGE_KEY = "socaBridgeModelsCache"; +const BRIDGE_MODELS_MESSAGE_TYPE = "SOCA_BRIDGE_GET_MODELS"; +const LOCAL_OLLAMA_PROVIDER: ModelsData = { + ollama: { + id: "ollama", + name: "Ollama (Local)", + npm: "@ai-sdk/openai-compatible", + api: "http://127.0.0.1:11434/v1", + models: { + "qwen3-vl:2b": { + id: "qwen3-vl:2b", + name: "Qwen3-VL 2B", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "qwen3-vl:4b": { + id: "qwen3-vl:4b", + name: "Qwen3-VL 4B", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "qwen3-vl:8b": { + id: "qwen3-vl:8b", + name: "Qwen3-VL 8B", + modalities: { input: ["text", "image"], output: ["text"] } + } + } + } +}; +const LOCAL_SOCA_BRIDGE_PROVIDER: ModelsData = { + "soca-bridge": { + id: "soca-bridge", + name: "SOCA Bridge (Local)", + npm: "@ai-sdk/openai-compatible", + api: "http://127.0.0.1:9834/v1", + models: { + "soca/auto": { + id: "soca/auto", + name: "SOCA Auto (via Bridge)", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "soca/fast": { + id: "soca/fast", + name: "SOCA Fast (via Bridge)", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "soca/best": { + id: "soca/best", + name: "SOCA Best (via Bridge)", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "qwen3-vl:2b": { + id: "qwen3-vl:2b", + name: "Qwen3-VL 2B (via Bridge)", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "qwen3-vl:4b": { + id: "qwen3-vl:4b", + name: "Qwen3-VL 4B (via Bridge)", + modalities: { input: ["text", "image"], output: ["text"] } + }, + "qwen3-vl:8b": { + id: "qwen3-vl:8b", + name: "Qwen3-VL 8B (via Bridge)", + modalities: { input: ["text", "image"], output: ["text"] } + } + } + } +}; +const DEFAULT_FALLBACK_MODELS: ModelsData = { + ...LOCAL_OLLAMA_PROVIDER, + ...LOCAL_SOCA_BRIDGE_PROVIDER +}; + +function guessVisionSupport(modelId: string): boolean { + const id = (modelId || "").toLowerCase(); + // Heuristic: prefer false-positives (more models shown) over missing likely vision models. + return ( + id.startsWith("soca/") || + id.includes("vl") || + id.includes("vision") || + id.includes("llava") || + id.includes("pixtral") || + id.includes("gpt-4o") || + id.includes("gpt-4.1") || + id.includes("claude") || + id.includes("gemini") + ); +} + +async function fetchBridgeModelIds(timeoutMs: number): Promise { + if (typeof chrome === "undefined" || !chrome?.runtime?.sendMessage) return []; + const resp = (await Promise.race([ + chrome.runtime.sendMessage({ type: BRIDGE_MODELS_MESSAGE_TYPE }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("bridge_models_timeout")), timeoutMs) + ) + ])) as any; + + if (!resp?.ok) { + throw new Error(String(resp?.err || "bridge_models_failed")); + } + const list = resp?.data?.data; + if (!Array.isArray(list)) return []; + return list.map((m: any) => String(m?.id || "").trim()).filter(Boolean); +} + +async function readModelsCache(): Promise { + try { + if (typeof chrome === "undefined" || !chrome?.storage?.local) { + return null; + } + const result = await chrome.storage.local.get([MODELS_CACHE_STORAGE_KEY]); + const cached = result[MODELS_CACHE_STORAGE_KEY] as ModelsData | undefined; + if (!cached || typeof cached !== "object") { + return null; + } + return cached; + } catch (error) { + console.warn("Failed to read models cache:", error); + return null; + } +} + +async function writeModelsCache(data: ModelsData): Promise { try { - const response = await fetch(MODELS_API_URL); - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.statusText}`); + if (typeof chrome === "undefined" || !chrome?.storage?.local) { + return; } - return await response.json(); + await chrome.storage.local.set({ [MODELS_CACHE_STORAGE_KEY]: data }); + } catch (error) { + console.warn("Failed to write models cache:", error); + } +} + +export async function fetchModelsData(options?: { + lane?: SocaOpenBrowserLane; +}): Promise { + if (options?.lane !== "OB_ONLINE_PULSE") { + return DEFAULT_FALLBACK_MODELS; + } + try { + const ids = await fetchBridgeModelIds(8000); + if (!ids.length) { + return DEFAULT_FALLBACK_MODELS; + } + + const bridgeModels: Record = {}; + for (const id of ids) { + bridgeModels[id] = { + id, + name: id, + modalities: guessVisionSupport(id) + ? { input: ["text", "image"], output: ["text"] } + : { input: ["text"], output: ["text"] } + }; + } + + const data: ModelsData = { + ...LOCAL_OLLAMA_PROVIDER, + "soca-bridge": { + ...LOCAL_SOCA_BRIDGE_PROVIDER["soca-bridge"], + models: { + ...LOCAL_SOCA_BRIDGE_PROVIDER["soca-bridge"].models, + ...bridgeModels + } + } + }; + await writeModelsCache(data); + return data; } catch (error) { console.error("Error fetching models:", error); - throw error; + const cached = await readModelsCache(); + if (cached) { + return cached; + } + return DEFAULT_FALLBACK_MODELS; } } @@ -113,7 +275,7 @@ export function modelsToOptions( * Get default base URL for a provider */ export function getDefaultBaseURL(providerId: string, api?: string): string { - // Use API from models.dev data if available + // Use provider-advertised API base URL if available. if (api) { return api; } diff --git a/chromium-extension/src/options/index.tsx b/chromium-extension/src/options/index.tsx index 021c5cd..9737770 100644 --- a/chromium-extension/src/options/index.tsx +++ b/chromium-extension/src/options/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; -import { Form, Input, Button, message, Select, Checkbox, Spin } from "antd"; +import { Form, Input, Button, message, Select, Spin } from "antd"; import { SaveOutlined, LoadingOutlined } from "@ant-design/icons"; import "../sidebar/index.css"; import { ThemeProvider } from "../sidebar/providers/ThemeProvider"; @@ -9,7 +9,8 @@ import { getProvidersWithImageSupport, providersToOptions, modelsToOptions, - getDefaultBaseURL + getDefaultBaseURL, + type SocaOpenBrowserLane } from "../llm/llm"; import type { Provider, @@ -18,25 +19,37 @@ import type { } from "../llm/llm.interface"; const { Option } = Select; +const SOCA_LANE_STORAGE_KEY = "socaOpenBrowserLane"; +const DEFAULT_SOCA_LANE: SocaOpenBrowserLane = "OB_OFFLINE"; + +function runtimeSendMessage(msg: any): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(msg, (resp) => { + const err = chrome.runtime.lastError; + if (err) return reject(new Error(String(err.message || err))); + resolve(resp as TResp); + }); + }); +} const OptionsPage = () => { const [form] = Form.useForm(); + const [laneLoaded, setLaneLoaded] = useState(false); + const [socaOpenBrowserLane, setSocaOpenBrowserLane] = + useState(DEFAULT_SOCA_LANE); + const [configLoaded, setConfigLoaded] = useState(false); + const [config, setConfig] = useState({ - llm: "anthropic", + llm: "ollama", apiKey: "", - modelName: "claude-sonnet-4-5-20250929", - npm: "@ai-sdk/anthropic", + modelName: "qwen3-vl:2b", + npm: "@ai-sdk/openai-compatible", options: { - baseURL: "https://api.anthropic.com/v1" + baseURL: "http://127.0.0.1:11434/v1" } }); - const [webSearchConfig, setWebSearchConfig] = useState({ - enabled: false, - apiKey: "" - }); - const [historyLLMConfig, setHistoryLLMConfig] = useState>( {} ); @@ -70,12 +83,36 @@ const OptionsPage = () => { } }, [isDarkMode]); - // Fetch models data on component mount + // Load lane on mount + useEffect(() => { + const loadLane = async () => { + try { + const laneResult = await chrome.storage.local.get([ + SOCA_LANE_STORAGE_KEY + ]); + const lane = + (laneResult[SOCA_LANE_STORAGE_KEY] as SocaOpenBrowserLane) || + DEFAULT_SOCA_LANE; + setSocaOpenBrowserLane(lane); + form.setFieldsValue({ [SOCA_LANE_STORAGE_KEY]: lane }); + } catch (error) { + console.error("Failed to load lane:", error); + message.error("Failed to load lane. Please refresh the page."); + } + }; + + loadLane().finally(() => setLaneLoaded(true)); + }, []); + + // Fetch models data whenever lane changes useEffect(() => { + if (!laneLoaded) return; + const loadModels = async () => { try { setLoading(true); - const data = await fetchModelsData(); + + const data = await fetchModelsData({ lane: socaOpenBrowserLane }); const imageProviders = getProvidersWithImageSupport(data); setProvidersData(imageProviders); @@ -99,79 +136,185 @@ const OptionsPage = () => { }; loadModels(); - }, []); + }, [laneLoaded, socaOpenBrowserLane]); // Load saved config from storage useEffect(() => { + if (!laneLoaded) return; if (Object.keys(providersData).length === 0) return; // Wait for providers to load - chrome.storage.sync.get( - ["llmConfig", "historyLLMConfig", "webSearchConfig"], - (result) => { + const loadSavedConfig = async () => { + form.setFieldsValue({ [SOCA_LANE_STORAGE_KEY]: socaOpenBrowserLane }); + + const fallbackProviderId = + Object.entries(providersData) + .map(([id, provider]) => ({ id, name: provider.name })) + .sort((a, b) => a.name.localeCompare(b.name))[0]?.id || "ollama"; + + if (!configLoaded) { + const result = await chrome.storage.local.get([ + "llmConfig", + "historyLLMConfig", + "socaBridgeConfig" + ]); + + if (result.historyLLMConfig) { + setHistoryLLMConfig(result.historyLLMConfig); + } + if (result.llmConfig) { if (result.llmConfig.llm === "") { - result.llmConfig.llm = "anthropic"; + result.llmConfig.llm = fallbackProviderId; + } + + if (!providersData[result.llmConfig.llm]) { + result.llmConfig.llm = fallbackProviderId; } if (!result.llmConfig.npm && providersData[result.llmConfig.llm]) { result.llmConfig.npm = providersData[result.llmConfig.llm].npm; } + if ( + !result.llmConfig.modelName || + !modelOptions[result.llmConfig.llm]?.some( + (m) => m.value === result.llmConfig.modelName + ) + ) { + result.llmConfig.modelName = + modelOptions[result.llmConfig.llm]?.[0]?.value || ""; + } + + if (!result.llmConfig.options?.baseURL) { + result.llmConfig.options = { + ...result.llmConfig.options, + baseURL: getDefaultBaseURL( + result.llmConfig.llm, + providersData[result.llmConfig.llm]?.api + ) + }; + } + + if ( + result.llmConfig.llm === "soca-bridge" && + typeof result.socaBridgeConfig?.bridgeBaseURL === "string" && + result.socaBridgeConfig.bridgeBaseURL.trim() + ) { + result.llmConfig.options = { + ...result.llmConfig.options, + baseURL: `${result.socaBridgeConfig.bridgeBaseURL.replace(/\/+$/, "")}/v1` + }; + } + setConfig(result.llmConfig); form.setFieldsValue(result.llmConfig); } - if (result.historyLLMConfig) { - setHistoryLLMConfig(result.historyLLMConfig); - } - if (result.webSearchConfig) { - setWebSearchConfig(result.webSearchConfig); - form.setFieldsValue({ - webSearchEnabled: result.webSearchConfig.enabled, - exaApiKey: result.webSearchConfig.apiKey - }); + + // Session-only bridge token prefill (never persisted). + try { + const sess = await (chrome.storage as any).session.get([ + "socaBridgeToken" + ]); + if (sess?.socaBridgeToken) { + form.setFieldValue("apiKey", String(sess.socaBridgeToken)); + } + } catch (e) { + // ignore } + + setConfigLoaded(true); + return; + } + + // On lane/provider refresh: only adjust config if it's now invalid. + if (!providersData[config.llm]) { + handleLLMChange(fallbackProviderId); + return; } - ); - }, [providersData]); + if ( + config.modelName && + !modelOptions[config.llm]?.some((m) => m.value === config.modelName) + ) { + const nextModel = modelOptions[config.llm]?.[0]?.value || ""; + const nextConfig = { ...config, modelName: nextModel }; + setConfig(nextConfig); + form.setFieldsValue(nextConfig); + } + }; + + loadSavedConfig().catch((error) => { + console.error("Failed to load saved config:", error); + }); + }, [ + laneLoaded, + providersData, + configLoaded, + socaOpenBrowserLane, + config, + modelOptions + ]); + + const handleSocaLaneChange = (lane: SocaOpenBrowserLane) => { + setSocaOpenBrowserLane(lane); + }; const handleSave = () => { - form - .validateFields() - .then((value) => { - const { webSearchEnabled, exaApiKey, ...llmConfigValue } = value; + (async () => { + try { + const value = await form.validateFields(); + const { socaOpenBrowserLane, ...llmConfigValue } = value as any; + const lane = + (socaOpenBrowserLane as SocaOpenBrowserLane) || DEFAULT_SOCA_LANE; + + // Session-only bridge token (never persisted to chrome.storage.local). + if (llmConfigValue.llm === "soca-bridge") { + const token = String(llmConfigValue.apiKey || "").trim(); + const r1 = await runtimeSendMessage({ + type: "SOCA_SET_BRIDGE_TOKEN", + token + }); + if (!r1?.ok) + throw new Error(String(r1?.err || "failed_to_set_bridge_token")); + + const baseURL = String(llmConfigValue?.options?.baseURL || "").trim(); + const bridgeBaseURL = baseURL + .replace(/\/+$/, "") + .replace(/\/v1$/, ""); + const r2 = await runtimeSendMessage({ + type: "SOCA_SET_BRIDGE_CONFIG", + config: { bridgeBaseURL, dnrGuardrailsEnabled: true } + }); + if (!r2?.ok) + throw new Error(String(r2?.err || "failed_to_set_bridge_config")); + + // Persist a non-secret placeholder only. + llmConfigValue.apiKey = ""; + } setConfig(llmConfigValue); setHistoryLLMConfig({ ...historyLLMConfig, [llmConfigValue.llm]: llmConfigValue }); + setSocaOpenBrowserLane(lane); - const newWebSearchConfig = { - enabled: webSearchEnabled || false, - apiKey: exaApiKey || "" - }; - setWebSearchConfig(newWebSearchConfig); - - chrome.storage.sync.set( - { - llmConfig: llmConfigValue, - historyLLMConfig: { - ...historyLLMConfig, - [llmConfigValue.llm]: llmConfigValue - }, - webSearchConfig: newWebSearchConfig + await chrome.storage.local.set({ + llmConfig: llmConfigValue, + historyLLMConfig: { + ...historyLLMConfig, + [llmConfigValue.llm]: llmConfigValue }, - () => { - message.success({ - content: "Save Success!", - className: "toast-text-black" - }); - } - ); - }) - .catch(() => { - message.error("Please check the form field"); - }); + [SOCA_LANE_STORAGE_KEY]: lane + }); + + message.success({ + content: "Save Success!", + className: "toast-text-black" + }); + } catch (e: any) { + message.error(String(e?.message || e || "Please check the form field")); + } + })(); }; const handleLLMChange = (value: string) => { @@ -180,10 +323,13 @@ const OptionsPage = () => { // Check if user has a saved config for this provider const savedConfig = historyLLMConfig[value]; + const defaultApiKey = + savedConfig?.apiKey || + (value === "ollama" ? "ollama" : value === "soca-bridge" ? "" : ""); const newConfig = { llm: value, - apiKey: savedConfig?.apiKey || "", + apiKey: defaultApiKey, modelName: savedConfig?.modelName || modelOptions[value]?.[0]?.value || "", npm: provider?.npm, @@ -195,6 +341,17 @@ const OptionsPage = () => { setConfig(newConfig); form.setFieldsValue(newConfig); + + if (value === "soca-bridge") { + (chrome.storage as any).session + .get(["socaBridgeToken"]) + .then((sess: any) => { + if (sess?.socaBridgeToken) { + form.setFieldValue("apiKey", String(sess.socaBridgeToken)); + } + }) + .catch(() => {}); + } }; const handleResetBaseURL = () => { @@ -217,24 +374,21 @@ const OptionsPage = () => { }); }; - if (loading) { - return ( -
- - } - /> -
- ); - } - return ( -
+
+ {loading && ( +
+ + } + /> +
+ )} {/* Header */}
@@ -265,7 +419,48 @@ const OptionsPage = () => { className="bg-theme-primary border-theme-input rounded-xl p-6" style={{ borderWidth: "1px", borderStyle: "solid" }} > -
+ + + SOCA Lane + + } + rules={[ + { + required: true, + message: "Please select a SOCA lane" + } + ]} + > + + + { onChange={handleLLMChange} size="large" className="w-full bg-theme-input border-theme-input text-theme-primary input-theme-focus radius-8px" - popupClassName="bg-theme-input border-theme-input dropdown-theme-items" + classNames={{ + popup: { + root: "bg-theme-input border-theme-input dropdown-theme-items" + } + }} > {providerOptions.map((provider) => ( -
- - - - Enable web search (Exa AI) - - - - - - prevValues.webSearchEnabled !== currentValues.webSearchEnabled - } - > - {({ getFieldValue }) => - getFieldValue("webSearchEnabled") ? ( - - Exa API Key{" "} - - (Optional) - - - } - tooltip="Uses free tier if not provided" - > - - - ) : null - } - -
- + + {/* Right: Send/Stop/New Session Button */} + {currentMessageId ? ( +
+ + setPbPreviewOpen(false)} + footer={[ + , + , + + ]} + > + + setPbPreviewDraft(event.target.value)} + rows={10} + /> + {pbResult?.stats && ( + + chars {pbResult.stats.chars_before || 0} →{" "} + {pbResult.stats.chars_after || 0} | tokens{" "} + {pbResult.stats.est_tokens_before || 0} →{" "} + {pbResult.stats.est_tokens_after || 0} + + )} + {pbResult?.policy && ( + + policy lane_allowed={String(pbResult.policy.lane_allowed)}{" "} + network_used= + {String(pbResult.policy.network_used)} model= + {pbResult.policy.model || "unknown"} + + )} + {pbResult?.rationale && pbResult.rationale.length > 0 && ( +
+ Rationale +
    + {pbResult.rationale.slice(0, 6).map((item, index) => ( +
  • {item}
  • + ))} +
+
+ )} + {pbResult?.mutations && pbResult.mutations.length > 0 && ( +
+ Mutations +
    + {pbResult.mutations.slice(0, 8).map((item, index) => ( +
  • + {item.type}: {item.note} +
  • + ))} +
+
+ )} + {pbResult?.redactions && pbResult.redactions.length > 0 && ( +
+ Redactions +
    + {pbResult.redactions.slice(0, 6).map((item, index) => ( +
  • + {item.type}: {item.note} +
  • + ))} +
+
+ )} + {pbResult?.diff?.data && ( + + )} +
+
+ + setPbLibraryOpen(false)} + footer={[ + , + , + + ]} + > + + setPbLibrarySearch(e.target.value)} + placeholder="Search prompts..." + allowClear + /> + ( + + , + , + + ]} + > + + {item.title} + {item.category && ( + {item.category} + )} + + } + description={ + + {item.prompt.slice(0, 160)} + {item.prompt.length > 160 ? "..." : ""} + + } + /> + + )} + /> + + + + setPbSavePromptOpen(false)} + onOk={saveCurrentPromptToLibrary} + okText="Save" + okButtonProps={{ disabled: !pbSaveTitle.trim() || !inputValue.trim() }} + > + +
+ Title + setPbSaveTitle(e.target.value)} + /> +
+
+ Category (optional) + setPbSaveCategory(e.target.value)} + /> +
+ + Stored locally in the extension (not synced). + +
+
); }; diff --git a/chromium-extension/src/sidebar/components/MessageItem.tsx b/chromium-extension/src/sidebar/components/MessageItem.tsx index a14868f..0169878 100644 --- a/chromium-extension/src/sidebar/components/MessageItem.tsx +++ b/chromium-extension/src/sidebar/components/MessageItem.tsx @@ -122,15 +122,15 @@ export const MessageItem: React.FC = ({
{/* User Icon */}
-
- +
+
{/* User Content */}
{message.content && ( -
+
{userContent}
)} @@ -151,13 +151,13 @@ export const MessageItem: React.FC = ({ : `data:${file.mimeType};base64,${file.base64Data}` } alt={file.filename} - className="max-w-full max-h-[200px] rounded border border-gray-200" + className="max-w-full max-h-[200px] radius-8px border border-theme-input" preview={false} /> ) : ( -
- - +
+ + {file.filename}
@@ -177,8 +177,8 @@ export const MessageItem: React.FC = ({
{/* AI Icon */}
-
- +
+
@@ -225,7 +225,7 @@ export const MessageItem: React.FC = ({ : `data:${item.mimeType};base64,${item.data}` } alt="Message file" - className="max-w-full my-2 rounded border border-gray-200" + className="max-w-full my-2 radius-8px border border-theme-input" /> ); } else if ( @@ -253,7 +253,7 @@ export const MessageItem: React.FC = ({ return null; }) ) : message.content ? ( -
+
) : message.status == "waiting" ? ( diff --git a/chromium-extension/src/sidebar/components/SidebarErrorBoundary.tsx b/chromium-extension/src/sidebar/components/SidebarErrorBoundary.tsx new file mode 100644 index 0000000..14bbbdf --- /dev/null +++ b/chromium-extension/src/sidebar/components/SidebarErrorBoundary.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Alert, Button, Space, Typography } from "antd"; + +const { Text } = Typography; + +type Props = { + children: React.ReactNode; +}; + +type State = { + hasError: boolean; + message: string; +}; + +export class SidebarErrorBoundary extends React.Component { + state: State = { hasError: false, message: "" }; + + static getDerivedStateFromError(error: unknown): State { + return { + hasError: true, + message: error instanceof Error ? error.message : String(error) + }; + } + + componentDidCatch(error: unknown) { + // Keep a breadcrumb in DevTools while avoiding noisy stack dumping in UI. + console.error("sidebar_render_error", error); + } + + private resetPanelState = async () => { + try { + if (typeof chrome !== "undefined" && chrome.storage?.local) { + const keysToRemove = [ + "socaPromptBuddySettings", + "socaPromptBuddyLibraryV1", + "socaOpenBrowserToolsConfig" + ]; + await chrome.storage.local.remove(keysToRemove); + } + } catch (error) { + console.warn("sidebar_reset_state_failed", error); + } finally { + window.location.reload(); + } + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ + + The panel hit a runtime error. You can reload or reset local + panel state. + + {this.state.message || "unknown_error"} + + } + /> + + + + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/chromium-extension/src/sidebar/components/TextItem.tsx b/chromium-extension/src/sidebar/components/TextItem.tsx index 9ec9896..3678971 100644 --- a/chromium-extension/src/sidebar/components/TextItem.tsx +++ b/chromium-extension/src/sidebar/components/TextItem.tsx @@ -11,7 +11,7 @@ interface TextItemProps { export const TextItem: React.FC = ({ text, streamDone }) => { return (
-
+
{!streamDone && } diff --git a/chromium-extension/src/sidebar/components/ThinkingItem.tsx b/chromium-extension/src/sidebar/components/ThinkingItem.tsx index 77814a0..931746d 100644 --- a/chromium-extension/src/sidebar/components/ThinkingItem.tsx +++ b/chromium-extension/src/sidebar/components/ThinkingItem.tsx @@ -37,9 +37,9 @@ export const ThinkingItem: React.FC = ({ label: (
{!streamDone ? ( - + ) : ( - + )} Thinking @@ -48,7 +48,10 @@ export const ThinkingItem: React.FC = ({ ), children: (
-
+
{!streamDone && } diff --git a/chromium-extension/src/sidebar/components/WebpageMentionInput.tsx b/chromium-extension/src/sidebar/components/WebpageMentionInput.tsx index 55f05f3..e419b83 100644 --- a/chromium-extension/src/sidebar/components/WebpageMentionInput.tsx +++ b/chromium-extension/src/sidebar/components/WebpageMentionInput.tsx @@ -491,7 +491,7 @@ export const WebpageMentionInput: React.FC = ({ {showDropdown && (
{loadingTabs ? (
diff --git a/chromium-extension/src/sidebar/components/WorkflowCard.tsx b/chromium-extension/src/sidebar/components/WorkflowCard.tsx index a974e3b..9fb24b8 100644 --- a/chromium-extension/src/sidebar/components/WorkflowCard.tsx +++ b/chromium-extension/src/sidebar/components/WorkflowCard.tsx @@ -66,8 +66,10 @@ export const WorkflowCard: React.FC = ({
)} {task.workflowConfirm === "pending" && ( -
- Execute this workflow? +
+ + Execute this workflow? +