From b461fc1b5a94710e46f535f532da08add194a46a Mon Sep 17 00:00:00 2001 From: arnaud assoumani Date: Tue, 27 Jan 2026 12:34:50 +0100 Subject: [PATCH 1/3] feat: add SOCA HOLOBIONT OS workflow templates and vault templates - Add 5 YAML workflow templates (preflight, vps-capsule, clawdbot-deploy, openbrowser-setup, evidence-bundle) - Add 5 vault templates for SOCA_HOLOBIONT_OS Obsidian vault - Add 3 agent prompts (workflow-executor, openbrowser-observer, vps-operator) - Add workflow documentation README Part of Constitution Rule 66: OpenBrowser Automation Workflows --- vault-templates/README.md | 53 +++++++ vault-templates/artifact_index.md | 76 +++++++++ vault-templates/decision_openbrowser.md | 86 ++++++++++ vault-templates/idea_automation.md | 71 +++++++++ vault-templates/workflow_run.md | 75 +++++++++ workflows/README.md | 78 +++++++++ workflows/prompts/openbrowser-observer.md | 130 +++++++++++++++ workflows/prompts/vps-operator.md | 176 +++++++++++++++++++++ workflows/prompts/workflow-executor.md | 159 +++++++++++++++++++ workflows/templates/clawdbot-deploy.yaml | 174 ++++++++++++++++++++ workflows/templates/evidence-bundle.yaml | 138 ++++++++++++++++ workflows/templates/openbrowser-setup.yaml | 125 +++++++++++++++ workflows/templates/preflight.yaml | 134 ++++++++++++++++ workflows/templates/vps-capsule.yaml | 173 ++++++++++++++++++++ 14 files changed, 1648 insertions(+) create mode 100644 vault-templates/README.md create mode 100644 vault-templates/artifact_index.md create mode 100644 vault-templates/decision_openbrowser.md create mode 100644 vault-templates/idea_automation.md create mode 100644 vault-templates/workflow_run.md create mode 100644 workflows/README.md create mode 100644 workflows/prompts/openbrowser-observer.md create mode 100644 workflows/prompts/vps-operator.md create mode 100644 workflows/prompts/workflow-executor.md create mode 100644 workflows/templates/clawdbot-deploy.yaml create mode 100644 workflows/templates/evidence-bundle.yaml create mode 100644 workflows/templates/openbrowser-setup.yaml create mode 100644 workflows/templates/preflight.yaml create mode 100644 workflows/templates/vps-capsule.yaml diff --git a/vault-templates/README.md b/vault-templates/README.md new file mode 100644 index 0000000..b238ca5 --- /dev/null +++ b/vault-templates/README.md @@ -0,0 +1,53 @@ +# SOCA HOLOBIONT OS Vault Templates for OpenBrowser + +These templates are designed for the `SOCA_HOLOBIONT_OS` Obsidian vault. +They support OpenBrowser automation workflows with proper SOCA metadata. + +## Vault Path + +Templates are intended for: `vaults/SOCA_HOLOBIONT_OS/` + +## Template Categories + +| Template | Purpose | Folder | +|----------|---------|--------| +| `workflow_run.md` | Document a workflow execution | `30_RUNS/` | +| `decision_openbrowser.md` | Record OpenBrowser-related decisions | `20_DECISIONS/` | +| `artifact_index.md` | Index evidence artifacts | `40_ARTIFACTS_INDEX/` | +| `idea_automation.md` | Capture automation improvement ideas | `10_IDEAS/` | + +## Required Frontmatter + +All vault notes MUST include: + +```yaml +--- +id: +created_utc: +updated_utc: +scope: SOCA +source: soca-run | human | import +links: [] +evidence: [] +--- +``` + +## Integration with OpenBrowser Workflows + +When a workflow completes: +1. Evidence bundle is written to `runs/_local//` +2. Vault note is created/updated with evidence pointers +3. Artifact index is updated with new evidence paths + +## Related + +- `tools/openbrowser/workflows/templates/` +- `core/SOCAcore/socakit.md` (5L Memory tiers) +- Constitution Rule 4 (Continuous Memory) +- Constitution Rule 66 (OpenBrowser Automation) + +--- +[SOCA-STAMP] +type: documentation +version: 1.0.0 +scope: vault-templates diff --git a/vault-templates/artifact_index.md b/vault-templates/artifact_index.md new file mode 100644 index 0000000..bc1412f --- /dev/null +++ b/vault-templates/artifact_index.md @@ -0,0 +1,76 @@ +--- +id: {{uuid}} +created_utc: {{created_utc}} +updated_utc: {{updated_utc}} +scope: SOCA +source: soca-run +artifact_type: openbrowser-evidence +links: [] +evidence: [] +--- + +# Artifact Index: OpenBrowser Evidence + +**Last Updated**: {{updated_utc}} +**Total Artifacts**: {{total_count}} + +## Recent Evidence Bundles + +| Date | Workflow | Lane | Evidence Dir | Manifest | +|------|----------|------|--------------|----------| +{{#bundles}} +| {{date}} | {{workflow}} | {{lane}} | `{{evidence_dir}}` | [[{{manifest_link}}]] | +{{/bundles}} + +## Evidence by Workflow Type + +### VPS Capsule Setup +{{#vps_capsule}} +- `{{evidence_dir}}` ({{date}}) +{{/vps_capsule}} + +### Clawdbot Deploy +{{#clawdbot_deploy}} +- `{{evidence_dir}}` ({{date}}) +{{/clawdbot_deploy}} + +### OpenBrowser Setup +{{#openbrowser_setup}} +- `{{evidence_dir}}` ({{date}}) +{{/openbrowser_setup}} + +### Preflight Inventory +{{#preflight}} +- `{{evidence_dir}}` ({{date}}) +{{/preflight}} + +## Screenshot Archive + +| Timestamp | Workflow | URL | Screenshot | +|-----------|----------|-----|------------| +{{#screenshots}} +| {{timestamp}} | {{workflow}} | {{url}} | `{{path}}` | +{{/screenshots}} + +## Hash Verification + +To verify evidence integrity: + +```bash +cd {{evidence_base_dir}} +for dir in */; do + echo "Verifying $dir..." + (cd "$dir" && sha256sum -c sha256.txt 2>/dev/null || shasum -a 256 -c sha256.txt) +done +``` + +## Notes + +{{notes}} + +--- + +[SOCA-STAMP] +type: vault-note +template: artifact_index +artifact_type: openbrowser-evidence diff --git a/vault-templates/decision_openbrowser.md b/vault-templates/decision_openbrowser.md new file mode 100644 index 0000000..5049354 --- /dev/null +++ b/vault-templates/decision_openbrowser.md @@ -0,0 +1,86 @@ +--- +id: {{uuid}} +created_utc: {{created_utc}} +updated_utc: {{updated_utc}} +scope: SOCA +source: human +decision_type: openbrowser +status: {{status}} +links: [] +evidence: [] +--- + +# Decision: {{title}} + +**Date**: {{date}} +**Status**: {{status}} +**Category**: OpenBrowser Automation + +## Context + +{{context}} + +## Problem Statement + +{{problem}} + +## Options Considered + +{{#options}} +### Option {{number}}: {{name}} + +**Description**: {{description}} + +**Pros**: +{{#pros}} +- {{.}} +{{/pros}} + +**Cons**: +{{#cons}} +- {{.}} +{{/cons}} + +{{/options}} + +## Decision + +**Chosen Option**: {{chosen_option}} + +**Rationale**: +{{rationale}} + +## Consequences + +### Positive +{{#positive_consequences}} +- {{.}} +{{/positive_consequences}} + +### Negative +{{#negative_consequences}} +- {{.}} +{{/negative_consequences}} + +## Implementation Notes + +{{implementation_notes}} + +## Related Workflows + +{{#related_workflows}} +- [[{{workflow_name}}]] +{{/related_workflows}} + +## Evidence + +{{#evidence}} +- `{{path}}` (sha256: `{{sha256}}`) +{{/evidence}} + +--- + +[SOCA-STAMP] +type: vault-note +template: decision_openbrowser +decision_id: {{uuid}} diff --git a/vault-templates/idea_automation.md b/vault-templates/idea_automation.md new file mode 100644 index 0000000..f604d89 --- /dev/null +++ b/vault-templates/idea_automation.md @@ -0,0 +1,71 @@ +--- +id: {{uuid}} +created_utc: {{created_utc}} +updated_utc: {{updated_utc}} +scope: SOCA +source: human +idea_type: automation +status: {{status}} +priority: {{priority}} +links: [] +evidence: [] +--- + +# Idea: {{title}} + +**Status**: {{status}} +**Priority**: {{priority}} +**Category**: OpenBrowser Automation + +## Summary + +{{summary}} + +## Problem/Opportunity + +{{problem_opportunity}} + +## Proposed Solution + +{{proposed_solution}} + +## Expected Benefits + +{{#benefits}} +- {{.}} +{{/benefits}} + +## Implementation Complexity + +**Effort**: {{effort}} +**Risk**: {{risk}} +**Dependencies**: {{dependencies}} + +## Related Workflows + +{{#related_workflows}} +- `{{workflow_name}}` - {{relationship}} +{{/related_workflows}} + +## Success Criteria + +{{#success_criteria}} +- [ ] {{.}} +{{/success_criteria}} + +## Open Questions + +{{#questions}} +- {{.}} +{{/questions}} + +## Notes + +{{notes}} + +--- + +[SOCA-STAMP] +type: vault-note +template: idea_automation +idea_id: {{uuid}} diff --git a/vault-templates/workflow_run.md b/vault-templates/workflow_run.md new file mode 100644 index 0000000..2e653af --- /dev/null +++ b/vault-templates/workflow_run.md @@ -0,0 +1,75 @@ +--- +id: {{uuid}} +created_utc: {{created_utc}} +updated_utc: {{updated_utc}} +scope: SOCA +source: soca-run +workflow: {{workflow_name}} +lane: {{lane}} +status: {{status}} +links: + - {{evidence_dir}} +evidence: + - path: {{evidence_dir}}/sha256.txt + sha256: {{sha256_of_sha256txt}} +--- + +# Workflow Run: {{workflow_name}} + +**Status**: {{status}} +**Lane**: {{lane}} +**Started**: {{started_utc}} +**Completed**: {{completed_utc}} + +## Summary + +{{summary}} + +## Tasks Executed + +| Task ID | Name | Status | HIL | +|---------|------|--------|-----| +{{#tasks}} +| {{id}} | {{name}} | {{status}} | {{hil_required}} | +{{/tasks}} + +## Evidence Bundle + +**Location**: `{{evidence_dir}}` + +| Artifact | SHA256 | +|----------|--------| +{{#artifacts}} +| {{name}} | `{{sha256}}` | +{{/artifacts}} + +## OpenBrowser Observations + +{{#observations}} +### {{name}} +- **URL**: {{url}} +- **Action**: {{action}} +- **Result**: {{result}} +- **Screenshot**: {{screenshot_path}} +{{/observations}} + +## HIL Decisions + +{{#hil_decisions}} +- **Task**: {{task_id}} +- **Decision**: {{decision}} +- **Timestamp**: {{timestamp}} +- **Notes**: {{notes}} +{{/hil_decisions}} + +## Notes + +{{notes}} + +--- + +[SOCA-STAMP] +type: vault-note +template: workflow_run +workflow: {{workflow_name}} +evidence_dir: {{evidence_dir}} diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000..0d85c5c --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,78 @@ +# OpenBrowser Workflows (SOCA HOLOBIONT OS) + +Fail-closed, auditable task workflows for OpenCode + OpenBrowser automation. + +## Architecture + +``` +OpenCode executes (bash/edit tools) -> OpenBrowser verifies (screenshots/snapshots) +``` + +OpenBrowser provides browser tools (open tab/screenshot/snapshot) but does not replace shell execution. +Terminal actions happen via OpenCode tools, and OpenBrowser is used for UI observation and verification. + +## Directory Structure + +``` +workflows/ + templates/ # Reusable YAML task templates + vps-capsule.yaml # VPS secure capsule setup + clawdbot-deploy.yaml # Clawdbot + Tailscale deployment + preflight.yaml # Universal preflight inventory + evidence-bundle.yaml # Evidence collection workflow + examples/ # Example workflow compositions + prompts/ # Agent prompts for workflow execution +``` + +## Workflow Contract + +Every workflow YAML follows the SOCA fail-closed pattern: +1. **PLAN** - Show command list +2. **APPROVE** - Wait for HIL approval +3. **EXECUTE** - Run commands one at a time +4. **VERIFY** - Check outputs and state +5. **EVIDENCE** - Write evidence bundle with sha256 + +## Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `OPENCODE_BROWSER_BACKEND` | Browser automation mode | `agent` | +| `SOCA_OPENBROWSER_BRIDGE_TOKEN` | Bridge auth token | `soca` | +| `SOCA_OPENBROWSER_BRIDGE_PORT` | Bridge server port | `9834` | + +## Usage + +### Via OpenCode + +Paste workflow into OpenCode chat: + +``` +Execute workflow from tools/openbrowser/workflows/templates/vps-capsule.yaml +For each task: (1) show plan, (2) wait for approval, (3) execute, (4) verify +Never read ~/.soca-secrets or print tokens. +``` + +### Via SOCA Bridge + +```bash +curl -X POST http://127.0.0.1:9834/soca/workflow/execute \ + -H "Authorization: Bearer soca" \ + -H "Content-Type: application/json" \ + -d '{"workflow": "vps-capsule", "dry_run": true}' +``` + +## Related + +- Constitution Rule 61 (SOCA Bridge) +- Constitution Rule 66 (OpenBrowser Automation) +- `tools/openbrowser/bridge/app.py` +- `.claude/commands/soca-bridge.md` + +--- +[SOCA-STAMP] +type: documentation +version: 1.0.0 +related: + - core/SOCAcore/CONSTITUTION.md + - tools/openbrowser/bridge/app.py diff --git a/workflows/prompts/openbrowser-observer.md b/workflows/prompts/openbrowser-observer.md new file mode 100644 index 0000000..15a0169 --- /dev/null +++ b/workflows/prompts/openbrowser-observer.md @@ -0,0 +1,130 @@ +# OpenBrowser Observer Prompt + +You are SOCA OpenBrowser Observer, specialized in UI verification. + +## Role + +Use OpenBrowser browser tools to: +- Capture visual evidence of UI states +- Verify dashboard configurations +- Confirm authentication prompts +- Document service health via UI + +## Available Tools + +| Tool | Purpose | Usage | +|------|---------|-------| +| `open_tab` | Open URL in headless browser | `open_tab(url)` | +| `screenshot` | Capture current tab | `screenshot()` | +| `snapshot` | Get DOM content | `snapshot()` | +| `close_tab` | Close current tab | `close_tab()` | + +## Observation Patterns + +### Pattern 1: Dashboard Health Check + +``` +1. open_tab("http://127.0.0.1:18789/") +2. screenshot() -> dashboard_health.png +3. snapshot() -> dashboard_dom.html +4. Analyze: Check for error indicators, status badges +5. close_tab() +6. Report findings with evidence paths +``` + +### Pattern 2: Authentication Verification + +``` +1. open_tab("") +2. screenshot() -> auth_prompt.png +3. Analyze: Confirm login/token prompt appears +4. Verify: No auto-login (allowTailscale=false) +5. close_tab() +6. Report: Auth required = TRUE/FALSE +``` + +### Pattern 3: Service Status Page + +``` +1. open_tab("http://127.0.0.1:/health") +2. snapshot() -> health_response.html +3. Parse JSON response +4. Extract: status, version, uptime +5. close_tab() +6. Report with extracted metrics +``` + +## Evidence Collection + +For each observation, capture: + +```yaml +observation: + id: OBS_ + timestamp: + url: + action: + screenshot: + snapshot: + analysis: | + + verdict: PASS | FAIL | INCONCLUSIVE + evidence_hash: +``` + +## Common Verification Checks + +### Clawdbot Dashboard +- [ ] Dashboard loads without errors +- [ ] Channel status shows "configured" +- [ ] Pairing status shows "active" +- [ ] No authentication bypass visible + +### Tailscale Serve +- [ ] HTTPS redirect works +- [ ] Token/password prompt appears +- [ ] No identity-based auto-login + +### SOCA Bridge +- [ ] Health endpoint returns 200 +- [ ] Models endpoint lists available models +- [ ] CORS headers present for extension + +## Output Format + +```markdown +## OpenBrowser Observation Report + +### OBS_01: Dashboard Health +- **URL**: http://127.0.0.1:18789/ +- **Timestamp**: 2026-01-27T12:00:00Z +- **Screenshot**: `evidence/obs_01_dashboard.png` +- **Verdict**: PASS + +**Analysis**: +Dashboard loaded successfully. All status indicators green. +No error banners visible. + +### OBS_02: Auth Verification +- **URL**: https://machine.tailnet.ts.net/ +- **Timestamp**: 2026-01-27T12:01:00Z +- **Screenshot**: `evidence/obs_02_auth.png` +- **Verdict**: PASS + +**Analysis**: +Login prompt displayed. Token field visible. +No auto-authentication (allowTailscale=false confirmed). +``` + +## Safety Rules + +- Only access localhost or explicitly approved URLs +- Never interact with login forms (observation only) +- Never capture sensitive data in screenshots +- Always hash and index evidence files + +--- +[SOCA-STAMP] +type: prompt +prompt: openbrowser-observer +version: 1.0.0 diff --git a/workflows/prompts/vps-operator.md b/workflows/prompts/vps-operator.md new file mode 100644 index 0000000..15b34b5 --- /dev/null +++ b/workflows/prompts/vps-operator.md @@ -0,0 +1,176 @@ +# VPS Operator Prompt (SOCA Fail-Closed) + +You are SOCA VPS Operator, responsible for secure VPS automation. + +## Role + +Execute VPS setup and management workflows with: +- Zero secret leakage +- Fail-closed security defaults +- HIL gates for all mutations +- Evidence-backed operations + +## Operating Constraints + +### NEVER +- Read files in `~/.soca-secrets/` +- Print, echo, or log tokens/passwords +- Bind services to 0.0.0.0 +- Execute destructive commands without approval +- Modify firewall rules (ufw, iptables) + +### ALWAYS +- Propose commands before execution +- Wait for approval on HIL-required tasks +- Use loopback (127.0.0.1) for services +- Capture evidence with sha256 hashes +- Report verification status + +## Secret Handling + +Secrets are stored in `~/.soca-secrets/` with mode 600. +Agent CANNOT read these files (permission denied by design). + +To use secrets: +```bash +# CORRECT: Reference by file path +clawdbot channels add --token-file ~/.soca-secrets/telegram_bot_token.txt + +# CORRECT: Load into env without printing +export API_KEY="$(cat ~/.soca-secrets/api_key.txt)" + +# WRONG: Never do these +cat ~/.soca-secrets/api_key.txt +echo $API_KEY +``` + +## Workflow Execution + +### Step 1: Preflight +```bash +# Read-only inventory +whoami +uname -a +node --version +docker --version +tailscale status +``` + +### Step 2: Capsule Setup (HIL) +```bash +# Create workspace +mkdir -p ~/soca-vps +cd ~/soca-vps + +# Create AGENTS.md (rules) +cat > AGENTS.md <<'EOF' +[rules content] +EOF + +# Create opencode.json (permissions) +cat > opencode.json <<'EOF' +[config content] +EOF +``` + +### Step 3: Service Deployment (HIL) +```bash +# Install services +curl -fsSL https://example.com/install.sh | bash + +# Configure with loopback +service config --bind 127.0.0.1 + +# Enable daemon +service enable --daemon +``` + +### Step 4: Tailscale Integration (HIL) +```bash +# Verify Tailscale +tailscale status + +# Configure Serve +tailscale serve status + +# Force token auth +# Edit config: allowTailscale=false +``` + +### Step 5: Evidence Collection +```bash +# Create evidence directory +mkdir -p ~/runs/$(date -u +%Y%m%dT%H%M%SZ)-evidence +cd ~/runs/*-evidence + +# Capture state +tailscale serve status --json > tailscale_serve.json +service status --json > service_status.json + +# Hash evidence +sha256sum * > sha256.txt +``` + +## OpenBrowser Integration + +After terminal setup, use OpenBrowser to verify: + +``` +OBS_01: Dashboard +- URL: http://127.0.0.1:/ +- Check: UI loads, no errors + +OBS_02: Auth Required +- URL: +- Check: Token prompt appears + +OBS_03: Health Endpoint +- URL: http://127.0.0.1:/health +- Check: Returns {"status": "ok"} +``` + +## Error Handling + +If a command fails: +1. Report the error clearly +2. Show relevant output/logs +3. Propose recovery options +4. Wait for HIL decision + +```markdown +**ERROR in Task T05** + +Command: `clawdbot channels add --channel telegram ...` +Exit code: 1 +Output: "Error: invalid token format" + +**Recovery Options**: +1. Verify token file exists and has correct format +2. Check token file permissions (should be 600) +3. Retry with manual token entry (requires HIL) + +**Waiting for decision...** +``` + +## Evidence Format + +```json +{ + "workflow": "vps-capsule-setup", + "operator": "soca-vps-operator", + "timestamp": "2026-01-27T12:00:00Z", + "lane": "L2_CONTROLLED_WRITE", + "tasks": [...], + "hil_decisions": [...], + "observations": [...], + "evidence_dir": "runs/20260127T120000Z-evidence", + "sha256_manifest": "sha256.txt" +} +``` + +--- +[SOCA-STAMP] +type: prompt +prompt: vps-operator +version: 1.0.0 +lane: L2_CONTROLLED_WRITE diff --git a/workflows/prompts/workflow-executor.md b/workflows/prompts/workflow-executor.md new file mode 100644 index 0000000..70481a7 --- /dev/null +++ b/workflows/prompts/workflow-executor.md @@ -0,0 +1,159 @@ +# OpenBrowser Workflow Executor Prompt + +You are SOCA Workflow Executor, operating in fail-closed mode. + +## Role + +Execute OpenBrowser workflows from YAML templates with: +- Strict plan-approve-execute-verify sequence +- Evidence collection at every step +- HIL gates for all mutations +- ZHV compliance for all claims + +## Operating Rules + +1. **NEVER** read or print secrets from `~/.soca-secrets/` +2. **ALWAYS** show command plan before execution +3. **WAIT** for explicit approval before each HIL step +4. **VERIFY** each step before proceeding +5. **CAPTURE** evidence for every operation + +## Workflow Execution Pattern + +For each task in the workflow: + +``` +1. PLAN + - Show: Task ID, name, goal + - Show: Exact commands to run + - Show: Expected outcomes + - Show: HIL requirement status + +2. APPROVE (if HIL required) + - Wait for explicit "GO" from user + - If "NOGO", skip or rollback + +3. EXECUTE + - Run commands one at a time + - Capture all outputs + - Note any errors or warnings + +4. VERIFY + - Check verification criteria + - Confirm expected state + - Report any deviations + +5. EVIDENCE + - Record task output + - Update evidence bundle + - Compute hashes +``` + +## OpenBrowser Observation Pattern + +When using browser tools for verification: + +``` +1. Open tab with target URL +2. Wait for page load +3. Take screenshot +4. Take DOM snapshot (if needed) +5. Analyze results +6. Close tab +7. Record evidence +``` + +## Evidence Bundle Format + +```json +{ + "workflow": "", + "timestamp": "", + "lane": "", + "tasks": [ + { + "id": "", + "name": "", + "status": "completed|failed|skipped", + "outputs": [""], + "hil_decision": "", + "evidence_files": [""] + } + ], + "observations": [ + { + "id": "", + "url": "", + "screenshot": "", + "result": "" + } + ] +} +``` + +## Example Session + +``` +User: Execute workflow from tools/openbrowser/workflows/templates/preflight.yaml + +Agent: +## Workflow: preflight-inventory +**Lane**: L0_SHADOW (read-only) +**Tasks**: 9 + +### Task T00: System Information +**Goal**: Capture basic system identity +**Commands**: +- whoami +- uname -a +- hostname + +**Ready to execute? [Approve/Skip]** + +User: Approve + +Agent: +**Executing T00...** + +$ whoami +arnaud + +$ uname -a +Darwin MacBook.local 24.0.0 Darwin Kernel Version 24.0.0 + +$ hostname +MacBook.local + +**Verification**: Outputs captured. No changes made. +**Status**: COMPLETED + +### Task T01: Node.js and npm versions +**Goal**: Check Node.js toolchain availability +**Commands**: +- node --version || echo "node not installed" +- npm --version || echo "npm not installed" +- pnpm --version || echo "pnpm not installed" + +**Ready to execute? [Approve/Skip]** + +[... continues for all tasks ...] +``` + +## Safety Rules + +- Default lane: L0_SHADOW (read-only) +- Network: DENY unless workflow explicitly requires +- Secrets: NEVER read, print, or echo +- Destructive commands: ALWAYS require HIL + +## Related + +- `tools/openbrowser/workflows/templates/` +- `core/SOCAcore/CONSTITUTION.md` Rule 66 +- `core/SOCAcore/socakit.md` + +--- +[SOCA-STAMP] +type: prompt +prompt: workflow-executor +version: 1.0.0 diff --git a/workflows/templates/clawdbot-deploy.yaml b/workflows/templates/clawdbot-deploy.yaml new file mode 100644 index 0000000..79ea03a --- /dev/null +++ b/workflows/templates/clawdbot-deploy.yaml @@ -0,0 +1,174 @@ +# SOCA OpenBrowser Workflow: Clawdbot Deployment +# Goal: Install and configure Clawdbot with Telegram + Tailscale Serve +# +# Prerequisites: +# - VPS capsule created (vps-capsule.yaml) +# - Secrets in ~/.soca-secrets/ +# - Tailscale installed and authenticated +# +# Lane: L2_CONTROLLED_WRITE (HIL required) + +meta: + name: clawdbot-deploy + version: "1.0.0" + lane: L2_CONTROLLED_WRITE + network: ALLOW # Required for installation + description: "Deploy Clawdbot with Telegram and Tailscale Serve" + tags: [clawdbot, telegram, tailscale, deployment] + depends_on: + - vps-capsule.yaml + +tasks: + - id: T00_verify_prereqs + name: "Verify Prerequisites" + goal: "Check capsule and secrets exist" + lane: L0_SHADOW + commands: + - test -d ~/soca-vps && echo "capsule: ok" || echo "capsule: missing" + - test -d ~/.soca-secrets && echo "secrets dir: ok" || echo "secrets dir: missing" + - test -f ~/.soca-secrets/telegram_bot_token.txt && echo "telegram token: exists" || echo "telegram token: missing" + - tailscale status 2>/dev/null | head -3 || echo "tailscale: not running" + verify: + - "capsule directory exists" + - "secrets directory exists" + - "telegram token file exists" + - "tailscale is running" + + - id: T01_install_clawdbot + name: "Install Clawdbot" + goal: "Download and install Clawdbot" + lane: L2_CONTROLLED_WRITE + hil_required: true + commands: + - curl -fsSL https://clawd.bot/install.sh | bash + - clawdbot --version + verify: + - "clawdbot binary installed" + - "version prints successfully" + + - id: T02_onboard_clawdbot + name: "Onboard Clawdbot Daemon" + goal: "Configure daemon with loopback + Tailscale Serve" + lane: L2_CONTROLLED_WRITE + hil_required: true + hil_note: "Wizard will prompt for choices. Select: loopback, serve, token" + commands: + - clawdbot onboard --install-daemon + wizard_choices: + gateway_bind: "loopback" + tailscale_mode: "serve" + auth_mode: "token" + verify: + - "clawdbot daemon running" + - "gateway bound to loopback" + + - id: T03_force_token_auth + name: "Force Token Authentication" + goal: "Disable Tailscale identity bypass" + lane: L2_CONTROLLED_WRITE + hil_required: true + description: | + Set gateway.auth.allowTailscale=false so token/password is always required, + even when connecting via Tailscale identity headers. + edit_file: ~/.clawdbot/clawdbot.json + edit_content: | + "gateway": { + "auth": { + "allowTailscale": false + } + } + verify: + - "clawdbot status shows allowTailscale=false" + + - id: T04_telegram_channel + name: "Add Telegram Channel" + goal: "Configure Telegram bot using token file" + lane: L2_CONTROLLED_WRITE + hil_required: true + commands: + - clawdbot channels add --channel telegram --token-file ~/.soca-secrets/telegram_bot_token.txt + - clawdbot channels status --probe + rules: + - "NEVER print the token" + - "Use --token-file to avoid shell history leak" + verify: + - "Telegram channel shows as configured/healthy" + + - id: T05_telegram_pairing + name: "Telegram Pairing" + goal: "Pair your Telegram account" + lane: L2_CONTROLLED_WRITE + hil_required: true + manual_steps: + - "DM the bot on Telegram" + - "Note the pairing code from the bot" + commands: + - clawdbot pairing list telegram + - "clawdbot pairing approve telegram # Replace with actual code" + verify: + - "After approval, bot responds to your DMs" + + - id: T06_tailscale_verify + name: "Verify Tailscale Serve" + goal: "Confirm Tailscale Serve routing" + lane: L0_SHADOW + commands: + - tailscale serve status --json + - tailscale status + verify: + - "Serve rules exist (not empty)" + - "Gateway still bound to loopback" + + - id: T07_health_check + name: "Health Check" + goal: "Run Clawdbot diagnostics" + lane: L0_SHADOW + commands: + - clawdbot doctor + - clawdbot status --deep + - clawdbot health + verify: + - "No critical errors" + - "All services healthy" + +evidence: + output_dir: "runs/_local/clawdbot-deploy" + artifacts: + - clawdbot_deploy.json + - tailscale_serve_status.json + - clawdbot_status_deep.json + - clawdbot_doctor.txt + - sha256.txt + commands: + - "mkdir -p ~/runs/$(date -u +%Y%m%dT%H%M%SZ)-evidence" + - "tailscale serve status --json > tailscale_serve_status.json" + - "clawdbot status --deep --json > clawdbot_status_deep.json 2>/dev/null || true" + - "clawdbot doctor > clawdbot_doctor.txt" + - "sha256sum * > sha256.txt" + +openbrowser_verify: + - id: OBS_01 + name: "Dashboard Screenshot" + goal: "Verify Clawdbot dashboard UI" + action: "open_tab + screenshot + snapshot" + url: "http://127.0.0.1:18789/" + check: + - "Channel configured?" + - "Pairing ok?" + - "Auth prompt present?" + + - id: OBS_02 + name: "Tailscale HTTPS Verify" + goal: "Confirm token required on tailnet" + action: "open_tab + screenshot" + url: "" # Replace with actual MagicDNS URL + check: + - "Login prompt appears" + - "Asks for token (allowTailscale=false)" + +--- +# SOCA-STAMP +# type: workflow +# workflow: clawdbot-deploy +# version: 1.0.0 +# lane: L2_CONTROLLED_WRITE diff --git a/workflows/templates/evidence-bundle.yaml b/workflows/templates/evidence-bundle.yaml new file mode 100644 index 0000000..0f942bc --- /dev/null +++ b/workflows/templates/evidence-bundle.yaml @@ -0,0 +1,138 @@ +# SOCA OpenBrowser Workflow: Evidence Bundle Collection +# Goal: Collect and hash evidence artifacts for ZHV compliance +# +# This workflow is typically called at the end of other workflows +# to ensure all evidence is properly captured and checksummed. +# +# Lane: L1_ASSISTED (writes only to evidence directories) + +meta: + name: evidence-bundle + version: "1.0.0" + lane: L1_ASSISTED + network: DENY + description: "Collect evidence artifacts with ZHV checksums" + tags: [evidence, zhv, audit, compliance] + +inputs: + workflow_name: + type: string + required: true + description: "Name of the parent workflow" + + artifacts: + type: list + required: true + description: "List of artifact files to include" + + output_dir: + type: string + default: "runs/_local" + description: "Base directory for evidence output" + +tasks: + - id: T00_create_evidence_dir + name: "Create Evidence Directory" + goal: "Create timestamped evidence directory" + lane: L1_ASSISTED + commands: + - export EVIDENCE_DIR="${output_dir}/$(date -u +%Y%m%dT%H%M%SZ)-${workflow_name}-evidence" + - mkdir -p "$EVIDENCE_DIR" + - echo "$EVIDENCE_DIR" + outputs: + - EVIDENCE_DIR + + - id: T01_collect_artifacts + name: "Collect Artifacts" + goal: "Copy all specified artifacts to evidence directory" + lane: L1_ASSISTED + commands: + - | + for artifact in ${artifacts}; do + if [ -f "$artifact" ]; then + cp "$artifact" "$EVIDENCE_DIR/" + fi + done + verify: + - "All artifacts copied" + + - id: T02_env_context + name: "Capture Environment Context" + goal: "Record sandbox and policy state" + lane: L1_ASSISTED + commands: + - | + cat > "$EVIDENCE_DIR/env_context.txt" < /dev/null 2>&1; then + cat > "$EVIDENCE_DIR/git_state.txt" </dev/null || echo "UNKNOWN") + branch: $(git branch --show-current 2>/dev/null || echo "UNKNOWN") + status: $(git status --porcelain 2>/dev/null | head -20 || echo "UNKNOWN") + EOF + else + echo "Not a git repository" > "$EVIDENCE_DIR/git_state.txt" + fi + verify: + - "git_state.txt created" + + - id: T04_compute_hashes + name: "Compute SHA256 Hashes" + goal: "Hash all evidence files for ZHV" + lane: L1_ASSISTED + commands: + - cd "$EVIDENCE_DIR" && sha256sum * > sha256.txt 2>/dev/null || shasum -a 256 * > sha256.txt + verify: + - "sha256.txt created" + - "All files hashed" + + - id: T05_create_manifest + name: "Create Evidence Manifest" + goal: "Generate JSON manifest for evidence bundle" + lane: L1_ASSISTED + commands: + - | + cat > "$EVIDENCE_DIR/manifest.json" <= 18 installed" + - "npm available" + + - id: T01_install_plugin + name: "Install OpenCode Browser Plugin" + goal: "Install opencode-browser plugin" + lane: L2_CONTROLLED_WRITE + hil_required: true + workdir: ~/soca-vps + commands: + - npx @different-ai/opencode-browser@latest install + verify: + - "Plugin installed successfully" + + - id: T02_install_agent_browser + name: "Install Agent Browser" + goal: "Install headless browser backend" + lane: L2_CONTROLLED_WRITE + hil_required: true + commands: + - npm install -g agent-browser + - agent-browser install + verify: + - "agent-browser installed globally" + - "Chromium/Playwright downloaded" + + - id: T03_configure_backend + name: "Configure Browser Backend" + goal: "Set agent-browser as default backend" + lane: L2_CONTROLLED_WRITE + commands: + - | + # Add to shell profile + echo 'export OPENCODE_BROWSER_BACKEND=agent' >> ~/.bashrc + echo 'export OPENCODE_BROWSER_BACKEND=agent' >> ~/.zshrc 2>/dev/null || true + + # Set for current session + export OPENCODE_BROWSER_BACKEND=agent + verify: + - "OPENCODE_BROWSER_BACKEND=agent in profile" + + - id: T04_verify_tools + name: "Verify Browser Tools Available" + goal: "Confirm OpenCode has browser tools" + lane: L0_SHADOW + description: | + Inside OpenCode, the following browser tools should be available: + - open_tab: Open a URL in headless browser + - screenshot: Take screenshot of current tab + - snapshot: Get DOM snapshot + - close_tab: Close browser tab + manual_check: + - "Run OpenCode and check for browser tools in tool list" + - "Try: open_tab with a test URL" + verify: + - "Browser tools visible in OpenCode" + + - id: T05_soca_bridge_check + name: "Check SOCA Bridge Integration" + goal: "Verify SOCA Bridge is accessible" + lane: L0_SHADOW + commands: + - curl -s http://127.0.0.1:9834/health || echo "bridge not running" + - curl -s -H "Authorization: Bearer soca" http://127.0.0.1:9834/v1/models | head -20 || echo "models endpoint failed" + verify: + - "Bridge health returns ok" + - "Models endpoint returns model list" + +evidence: + output_dir: "runs/_local/openbrowser-setup" + artifacts: + - openbrowser_setup.json + - node_version.txt + - plugin_install.log + - sha256.txt + +openbrowser_verify: + - id: OBS_01 + name: "Test Screenshot Capability" + goal: "Verify browser can take screenshots" + action: "open_tab + screenshot" + url: "https://example.com" + check: + - "Screenshot captured successfully" + - "Page content visible" + + - id: OBS_02 + name: "Test Localhost Access" + goal: "Verify browser can access localhost" + action: "open_tab + snapshot" + url: "http://127.0.0.1:9834/health" + check: + - "Response shows status: ok" + +--- +# SOCA-STAMP +# type: workflow +# workflow: openbrowser-setup +# version: 1.0.0 +# lane: L2_CONTROLLED_WRITE diff --git a/workflows/templates/preflight.yaml b/workflows/templates/preflight.yaml new file mode 100644 index 0000000..d6b384f --- /dev/null +++ b/workflows/templates/preflight.yaml @@ -0,0 +1,134 @@ +# SOCA OpenBrowser Workflow: Universal Preflight Inventory +# Goal: prove current system state before any changes (read-only) +# +# Usage: Paste into OpenCode or reference from workflow runner +# Lane: L0_SHADOW (read-only, no mutations) + +meta: + name: preflight-inventory + version: "1.0.0" + lane: L0_SHADOW + network: DENY + description: "Prove current state before changes (read-only)" + tags: [preflight, inventory, audit] + +agents: + rules_file: AGENTS.md + permission_overrides: + read: allow + bash: ask + edit: deny + +tasks: + - id: T00_system_info + name: "System Information" + goal: "Capture basic system identity" + lane: L0_SHADOW + commands: + - whoami + - uname -a + - hostname + verify: + - "outputs captured" + - "no changes made" + + - id: T01_node_npm + name: "Node.js and npm versions" + goal: "Check Node.js toolchain availability" + lane: L0_SHADOW + commands: + - node --version || echo "node not installed" + - npm --version || echo "npm not installed" + - pnpm --version || echo "pnpm not installed" + verify: + - "version strings captured or 'not installed' noted" + + - id: T02_python + name: "Python environment" + goal: "Check Python availability" + lane: L0_SHADOW + commands: + - python3 --version || echo "python3 not installed" + - pip3 --version || echo "pip3 not installed" + verify: + - "version strings captured" + + - id: T03_docker + name: "Docker availability" + goal: "Check Docker daemon status" + lane: L0_SHADOW + commands: + - docker --version || echo "docker not installed" + - docker compose version || echo "docker compose not available" + - docker ps --format '{{.Names}}' 2>/dev/null | head -5 || echo "docker not running or no containers" + verify: + - "docker status captured" + + - id: T04_git + name: "Git status" + goal: "Capture git repository state" + lane: L0_SHADOW + commands: + - git --version + - git status --porcelain 2>/dev/null | head -10 || echo "not a git repo" + - git branch --show-current 2>/dev/null || echo "no branch" + verify: + - "git state captured" + + - id: T05_tailscale + name: "Tailscale status" + goal: "Check Tailscale connectivity" + lane: L0_SHADOW + commands: + - tailscale status 2>/dev/null || echo "tailscale not installed or not running" + verify: + - "tailscale status captured" + + - id: T06_disk + name: "Disk usage" + goal: "Capture disk state (Rule 30 compliance)" + lane: L0_SHADOW + commands: + - df -h . | tail -1 + - du -sh ~/.soca-secrets 2>/dev/null || echo "~/.soca-secrets does not exist" + verify: + - "disk usage captured" + - "secrets dir existence noted (not contents)" + + - id: T07_opencode + name: "OpenCode version" + goal: "Verify OpenCode installation" + lane: L0_SHADOW + commands: + - opencode --version 2>/dev/null || echo "opencode not installed" + verify: + - "opencode version captured" + + - id: T08_soca_bridge + name: "SOCA Bridge status" + goal: "Check SOCA Bridge availability" + lane: L0_SHADOW + commands: + - curl -s http://127.0.0.1:9834/health 2>/dev/null || echo "bridge not running" + verify: + - "bridge health captured" + +evidence: + output_dir: "runs/_local/preflight" + artifacts: + - preflight_inventory.json + - sha256.txt + format: | + { + "workflow": "preflight-inventory", + "timestamp": "", + "tasks": [], + "lane": "L0_SHADOW" + } + +--- +# SOCA-STAMP +# type: workflow +# workflow: preflight-inventory +# version: 1.0.0 +# lane: L0_SHADOW diff --git a/workflows/templates/vps-capsule.yaml b/workflows/templates/vps-capsule.yaml new file mode 100644 index 0000000..447e623 --- /dev/null +++ b/workflows/templates/vps-capsule.yaml @@ -0,0 +1,173 @@ +# SOCA OpenBrowser Workflow: VPS Secure Capsule Setup +# Goal: Create a fail-closed "SOCA capsule" workspace on a VPS +# +# Prerequisites: +# - SSH access to VPS +# - Manual secret placement (never paste tokens into agent chat) +# +# Lane: L2_CONTROLLED_WRITE (HIL required for writes) + +meta: + name: vps-capsule-setup + version: "1.0.0" + lane: L2_CONTROLLED_WRITE + network: DENY + description: "Create secure SOCA capsule workspace on VPS" + tags: [vps, capsule, security, setup] + +agents: + rules_file: AGENTS.md + rules_content: | + # SOCA VPS Capsule (Fail-Closed) + + - No public exposure: never bind services to 0.0.0.0 unless explicitly requested. + - No secrets in chat/log output: never echo tokens/keys; never print files from ~/.soca-secrets. + - Propose -> then execute: always provide a command plan first; run one command at a time. + - Prefer loopback + Tailscale Serve for admin surfaces. + - Deny destructive commands unless explicitly approved. + + Success criteria: + - Capsule workspace created + - Secrets directory exists with correct permissions + - OpenCode config installed + - AGENTS.md rules installed + + permission_config: + "$schema": "https://opencode.ai/config.json" + instructions: ["AGENTS.md"] + model: "openrouter/anthropic/claude-opus-4.5" + permission: + "*": "ask" + read: + "*": "ask" + "~/.soca-secrets/*": "deny" + bash: + "*": "ask" + "rm *": "deny" + "sudo rm *": "deny" + "ufw *": "deny" + "iptables *": "deny" + edit: "ask" + webfetch: "ask" + task: "ask" + +tasks: + - id: T00_preflight + name: "Preflight Inventory" + goal: "Prove current state before changes" + lane: L0_SHADOW + include: preflight.yaml + verify: + - "all preflight outputs captured" + - "no changes made" + + - id: T01_secrets_dir + name: "Create Secrets Directory (HIL step)" + goal: "Create a secrets dir that agents cannot read" + lane: L2_CONTROLLED_WRITE + hil_required: true + hil_note: "This creates the secrets directory. You must manually add secrets via nano." + commands: + - mkdir -p ~/.soca-secrets + - chmod 700 ~/.soca-secrets + manual_steps: + - "nano ~/.soca-secrets/openrouter_api_key.txt # Paste API key, save, exit" + - "nano ~/.soca-secrets/telegram_bot_token.txt # Paste bot token, save, exit" + - "chmod 600 ~/.soca-secrets/*.txt" + verify: + - "~/.soca-secrets exists with mode 700" + - "secret files have mode 600" + rules: + - "NEVER paste tokens into OpenCode chat" + - "NEVER read or print secret file contents" + + - id: T02_workspace + name: "Create Capsule Workspace" + goal: "Create SOCA capsule directory structure" + lane: L2_CONTROLLED_WRITE + commands: + - mkdir -p ~/soca-vps + - cd ~/soca-vps && pwd + verify: + - "~/soca-vps directory exists" + + - id: T03_agents_md + name: "Install AGENTS.md Rules" + goal: "Create OpenCode rules file" + lane: L2_CONTROLLED_WRITE + commands: + - | + cat > ~/soca-vps/AGENTS.md <<'EOF' + # SOCA VPS Capsule (Fail-Closed) + + - No public exposure: never bind services to 0.0.0.0 unless explicitly requested. + - No secrets in chat/log output: never echo tokens/keys; never print files from ~/.soca-secrets. + - Propose -> then execute: always provide a command plan first; run one command at a time. + - Prefer loopback + Tailscale Serve for admin surfaces. + - Deny destructive commands unless explicitly approved. + + Success criteria: + - Clawdbot daemon running + - Telegram configured with pairing + - Gateway bound to loopback + - Dashboard reachable via Tailscale Serve + - Token required even on tailnet (gateway.auth.allowTailscale=false) + EOF + verify: + - "~/soca-vps/AGENTS.md exists" + - "content matches expected rules" + + - id: T04_opencode_config + name: "Install OpenCode Config" + goal: "Create opencode.json with permissions" + lane: L2_CONTROLLED_WRITE + commands: + - | + cat > ~/soca-vps/opencode.json <<'EOF' + { + "$schema": "https://opencode.ai/config.json", + "instructions": ["AGENTS.md"], + "model": "openrouter/anthropic/claude-opus-4.5", + "permission": { + "*": "ask", + "read": { "*": "ask", "~/.soca-secrets/*": "deny" }, + "bash": { "*": "ask", "rm *": "deny", "sudo rm *": "deny", "ufw *": "deny", "iptables *": "deny" }, + "edit": "ask", + "webfetch": "ask", + "task": "ask" + } + } + EOF + verify: + - "~/soca-vps/opencode.json exists" + - "permission.read.~/.soca-secrets/* = deny" + +evidence: + output_dir: "runs/_local/vps-capsule" + artifacts: + - vps_capsule_setup.json + - agents_md_content.txt + - opencode_json_content.txt + - sha256.txt + format: | + { + "workflow": "vps-capsule-setup", + "timestamp": "", + "tasks": [], + "lane": "L2_CONTROLLED_WRITE", + "hil_decisions": [] + } + +openbrowser_verify: + - id: OBS_01 + name: "Verify workspace structure" + action: "Take screenshot of file listing" + commands: + - ls -la ~/soca-vps/ + +--- +# SOCA-STAMP +# type: workflow +# workflow: vps-capsule-setup +# version: 1.0.0 +# lane: L2_CONTROLLED_WRITE From bd4c1a9cdd9007b39e41ab5d82740cea54786924 Mon Sep 17 00:00:00 2001 From: arnaudassoumani-collab Date: Tue, 10 Feb 2026 03:15:11 +0100 Subject: [PATCH 2/3] soca: add bridge + app builder actions + L3 E2E gates --- .gitignore | 6 +- README.md | 7 + app_builder/README.md | 24 + .../SOCA_APP_BUILDER_BLUEPRINT.v1.json | 133 ++ .../actions/antigravity.actions.v1.json | 32 + .../google_ai_studio_build.actions.v1.json | 52 + app_builder/actions/lovable.actions.v1.json | 58 + app_builder/run_action_spec.ts | 682 ++++++ bridge/PROMPTBUDDY.md | 22 + bridge/app.py | 1983 +++++++++++++++++ bridge/openapi_snapshot.json | 1430 ++++++++++++ bridge/promptbuddy_evidence.py | 77 + bridge/promptbuddy_models.py | 147 ++ bridge/promptbuddy_routes.py | 315 +++ bridge/promptbuddy_service.py | 223 ++ bridge/promptbuddy_static_gate.py | 80 + bridge/version.py | 6 + chromium-extension/e2e/fixtures.ts | 72 + chromium-extension/e2e/no-egress.spec.ts | 150 ++ chromium-extension/package.json | 4 +- chromium-extension/playwright.config.ts | 11 + chromium-extension/public/manifest.json | 32 +- .../scripts/assert_no_all_urls.sh | 20 + .../scripts/assert_no_models_dev.sh | 9 + chromium-extension/scripts/l3_run_all.sh | 79 + .../src/background/agent/browser-service.ts | 74 +- .../src/background/agent/chat-service.ts | 121 +- .../src/background/bridge-client.ts | 283 +++ chromium-extension/src/background/index.ts | 693 +++++- .../src/background/soca-bridge.ts | 11 + chromium-extension/src/llm/llm.ts | 184 +- chromium-extension/src/options/index.tsx | 428 ++-- .../src/sidebar/components/ChatInput.tsx | 1034 ++++++++- .../src/sidebar/components/MessageItem.tsx | 22 +- .../src/sidebar/components/TextItem.tsx | 2 +- .../src/sidebar/components/ThinkingItem.tsx | 9 +- .../components/WebpageMentionInput.tsx | 2 +- .../src/sidebar/components/WorkflowCard.tsx | 11 +- chromium-extension/src/sidebar/index.css | 24 + chromium-extension/src/sidebar/index.tsx | 179 +- .../core/src/agent/browser/browser-labels.ts | 37 + .../core/src/agent/browser/build-dom-tree.ts | 211 +- packages/core/src/common/utils.ts | 262 +++ packages/core/src/llm/react.ts | 27 +- packages/core/src/memory/memory.ts | 3 +- packages/extension/src/browser.ts | 436 +++- workflows/templates.lock.json | 33 + workflows/templates/vps-preflight.yaml | 131 ++ workflows/templates/vps-setup.yaml | 186 ++ 49 files changed, 9651 insertions(+), 406 deletions(-) create mode 100644 app_builder/README.md create mode 100644 app_builder/SOCA_APP_BUILDER_BLUEPRINT.v1.json create mode 100644 app_builder/actions/antigravity.actions.v1.json create mode 100644 app_builder/actions/google_ai_studio_build.actions.v1.json create mode 100644 app_builder/actions/lovable.actions.v1.json create mode 100644 app_builder/run_action_spec.ts create mode 100644 bridge/PROMPTBUDDY.md create mode 100644 bridge/app.py create mode 100644 bridge/openapi_snapshot.json create mode 100644 bridge/promptbuddy_evidence.py create mode 100644 bridge/promptbuddy_models.py create mode 100644 bridge/promptbuddy_routes.py create mode 100644 bridge/promptbuddy_service.py create mode 100644 bridge/promptbuddy_static_gate.py create mode 100644 bridge/version.py create mode 100644 chromium-extension/e2e/fixtures.ts create mode 100644 chromium-extension/e2e/no-egress.spec.ts create mode 100644 chromium-extension/playwright.config.ts create mode 100755 chromium-extension/scripts/assert_no_all_urls.sh create mode 100755 chromium-extension/scripts/assert_no_models_dev.sh create mode 100755 chromium-extension/scripts/l3_run_all.sh create mode 100644 chromium-extension/src/background/bridge-client.ts create mode 100644 chromium-extension/src/background/soca-bridge.ts create mode 100644 workflows/templates.lock.json create mode 100644 workflows/templates/vps-preflight.yaml create mode 100644 workflows/templates/vps-setup.yaml 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..949ca58 100644 --- a/chromium-extension/package.json +++ b/chromium-extension/package.json @@ -3,7 +3,9 @@ "version": "1.0.0", "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..4c24f6b 100644 --- a/chromium-extension/public/manifest.json +++ b/chromium-extension/public/manifest.json @@ -20,25 +20,29 @@ "page": "options.html", "open_in_tab": true }, - "content_scripts": [ - { - "run_at": "document_idle", - "matches": [""], - "js": ["js/content_script.js"] - } - ], "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/*" ], - "host_permissions": [""], + "optional_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..3848d00 100644 --- a/chromium-extension/src/background/index.ts +++ b/chromium-extension/src/background/index.ts @@ -12,16 +12,40 @@ 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, + SOCA_LANE_STORAGE_KEY, + SOCA_TOOLS_CONFIG_STORAGE_KEY, + bridgeFetchJson, + ensureDnrGuardrailsInstalled, + getBridgeConfig, + getBridgeToken, + loadSocaToolsConfig, + normalizeLane, + setBridgeConfig, + setBridgeToken, + 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"; // Chat callback const chatCallback = { @@ -159,40 +183,141 @@ const taskCallback: AgentStreamCallback & HumanCallback = { } }; +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}`) + ); +} + async function loadLLMs(): Promise { const storageKey = "llmConfig"; - const llmConfig = (await chrome.storage.sync.get([storageKey]))[storageKey]; - if (!llmConfig || !llmConfig.apiKey) { + const llmConfig = ((await chrome.storage.local.get([storageKey]))[ + storageKey + ] || {}) as any; + const providerId = String(llmConfig?.llm || "soca-bridge"); + const modelName = String(llmConfig?.modelName || "soca/auto"); + const npm = String(llmConfig?.npm || "@ai-sdk/openai-compatible"); + + // Hard fail-closed: the extension never talks to public model endpoints. + if (providerId !== "soca-bridge" && providerId !== "ollama") { printLog( - "Please configure apiKey in the OpenBrowser extension options.", + `Direct provider '${providerId}' is disabled (no direct internet egress). Use 'soca-bridge' (recommended) or local 'ollama'.`, "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: providerId as any, + model: modelName, + // Session-only token for the bridge; this never lands 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"; + } + throw new Error(`provider_not_allowed:${provider}`); + }, + npm, config: { - baseURL: llmConfig.options.baseURL + baseURL: async () => { + const provider = String((llms.default as any).provider || ""); + if (provider === "soca-bridge") { + const cfg = await getBridgeConfig(); + return cfg.bridgeBaseURL.replace(/\/+$/, "") + "/v1"; + } + if (provider === "ollama") { + const baseURL = String( + ( + (await chrome.storage.local.get([storageKey]))[ + storageKey + ] as any + )?.options?.baseURL || "http://127.0.0.1:11434/v1" + ).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; + } + throw new Error(`provider_not_allowed:${provider}`); + }, + 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]) { + if (areaName === "local" && 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"); } } @@ -201,21 +326,214 @@ async function loadLLMs(): Promise { return llms; } +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)); + } + }); + } + + // nanobanapro: intentionally not wired here yet (no stable local bridge contract). + return tools; +} + async function init(chatId?: string): Promise { - initAgentServices(); - - const llms = 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) => { - printLog("init messages error: " + e, "error"); - }); + try { + initAgentServices(); + await ensureDnrGuardrailsInstalled(); + + const llms = await loadLLMs(); + const agents = [new BrowserAgent(), new WriteFileAgent()]; - return chatAgent; + const toolsConfig = await loadSocaToolsConfig(); + const socaTools = toolsConfig.mcp + ? createSocaBridgeTools({ + enabled: { + ...DEFAULT_SOCA_TOOLS_CONFIG.mcp, + ...toolsConfig.mcp + } + }) + : []; + + chatAgent = new ChatAgent({ llms, agents }, chatId, undefined, socaTools); + currentChatId = chatId || null; + chatAgent.initMessages().catch((e) => { + printLog("init messages error: " + e, "error"); + }); + return chatAgent; + } catch (error) { + chatAgent = null; + currentChatId = null; + printLog(`init failed: ${String(error)}`, "error"); + } } // Handle chat request @@ -374,6 +692,84 @@ async function handleGetTabs(requestId: string, data: any): Promise { } } +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: String(error) } + }); + } +} + +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: String(error) } + }); + } +} + // Event routing mapping const eventHandlers: Record< string, @@ -383,16 +779,211 @@ 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 && + 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_BRIDGE_CONFIG") { + await setBridgeConfig(request.config as BridgeConfig); + 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_TEST_TRY_FETCH") { + const url = String(request.url || ""); + try { + const r = await fetch(url); + sendResponse({ ok: false, note: `unexpected_success:${r.status}` }); + } catch (e: any) { + sendResponse({ ok: true, err: String(e?.message || e) }); + } + 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,8 +994,30 @@ 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({ @@ -420,3 +1033,11 @@ if ((chrome as any).sidePanel) { // open panel on action click (chrome as any).sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); } + +chrome.runtime.onInstalled.addListener(() => { + void ensureDnrGuardrailsInstalled(); +}); + +(chrome.runtime as any).onStartup?.addListener(() => { + 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/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? +
+ + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/chromium-extension/src/sidebar/index.tsx b/chromium-extension/src/sidebar/index.tsx index fdd6b20..7858b1c 100644 --- a/chromium-extension/src/sidebar/index.tsx +++ b/chromium-extension/src/sidebar/index.tsx @@ -5,12 +5,14 @@ import { ChatInput } from "./components/ChatInput"; import { SessionHistory } from "./components/SessionHistory"; import { useFileUpload } from "./hooks/useFileUpload"; import { MessageItem } from "./components/MessageItem"; +import { SidebarErrorBoundary } from "./components/SidebarErrorBoundary"; import type { ChatMessage, UploadedFile } from "./types"; import { useChatCallbacks } from "./hooks/useChatCallbacks"; import { useSessionManagement } from "./hooks/useSessionManagement"; import { ThemeProvider } from "./providers/ThemeProvider"; import { message as AntdMessage } from "antd"; import React, { useState, useRef, useEffect, useCallback } from "react"; +import { clampText } from "./utils/sanitize"; const AppRun = () => { const [messages, setMessages] = useState([]); @@ -25,14 +27,20 @@ const AppRun = () => { // Keep MV3 service worker alive while the sidebar is open (active automation). useEffect(() => { - const port = chrome.runtime.connect({ name: "SOCA_KEEPALIVE" }); + let port: chrome.runtime.Port | null = null; + try { + port = chrome.runtime.connect({ name: "SOCA_KEEPALIVE" }); + } catch (error) { + console.warn("sidebar_keepalive_connect_failed", error); + return; + } const ping = () => port.postMessage({ type: "PING" }); ping(); const id = window.setInterval(ping, 20_000); return () => { window.clearInterval(id); try { - port.disconnect(); + port?.disconnect(); } catch {} }; }, []); @@ -117,7 +125,7 @@ const AppRun = () => { } } else if (message.type === "log") { const level = message.data.level; - const msg = message.data.message; + const msg = clampText(String(message.data.message || ""), 800); const showMessage = level === "error" ? AntdMessage.error @@ -273,8 +281,17 @@ const AppRun = () => { }, [currentMessageId, stopMessage]); const handleNewSession = useCallback(() => { - newSession(setMessages, setCurrentMessageId, messages.length); - }, [newSession, messages.length]); + const shouldReset = + messages.length > 0 || + inputValue.trim().length > 0 || + uploadedFiles.length > 0; + if (shouldReset) { + setInputValue(""); + setUploadedFiles([]); + } + const effectiveLength = shouldReset ? Math.max(messages.length, 1) : 0; + newSession(setMessages, setCurrentMessageId, effectiveLength); + }, [newSession, messages.length, inputValue, uploadedFiles.length]); const handleSelectSession = useCallback( (sessionId: string) => { @@ -300,11 +317,11 @@ const AppRun = () => { }, [handleNewSession]); return ( -
+
{/* Message area */}
{messages.length === 0 ? (
@@ -367,7 +384,9 @@ const root = createRoot(document.getElementById("root")!); root.render( - + + + ); diff --git a/chromium-extension/src/sidebar/providers/ThemeProvider.tsx b/chromium-extension/src/sidebar/providers/ThemeProvider.tsx index 4a35d2d..d6e4ef3 100644 --- a/chromium-extension/src/sidebar/providers/ThemeProvider.tsx +++ b/chromium-extension/src/sidebar/providers/ThemeProvider.tsx @@ -5,37 +5,156 @@ interface ThemeProviderProps { children: React.ReactNode; } +type Rgb = { r: number; g: number; b: number }; + +function parseColor(value: string | undefined): Rgb | null { + if (!value) return null; + const input = value.trim(); + if (!input) return null; + + const hexMatch = input.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (hexMatch) { + const hex = hexMatch[1]; + if (hex.length === 3) { + return { + r: parseInt(hex[0] + hex[0], 16), + g: parseInt(hex[1] + hex[1], 16), + b: parseInt(hex[2] + hex[2], 16) + }; + } + if (hex.length === 8) { + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16) + }; + } + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16) + }; + } + + const rgbMatch = input.match( + /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i + ); + if (!rgbMatch && typeof document !== "undefined") { + const probe = document.createElement("span"); + probe.style.color = input; + document.body.appendChild(probe); + const computed = getComputedStyle(probe).color; + document.body.removeChild(probe); + const normalized = computed.match( + /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i + ); + if (normalized) { + const [r, g, b] = normalized.slice(1, 4).map((v) => Number(v)); + if (![r, g, b].some((v) => Number.isNaN(v) || v < 0 || v > 255)) { + return { r, g, b }; + } + } + return null; + } + if (!rgbMatch) return null; + const [r, g, b] = rgbMatch.slice(1, 4).map((v) => Number(v)); + if ([r, g, b].some((v) => Number.isNaN(v) || v < 0 || v > 255)) { + return null; + } + return { r, g, b }; +} + +function luminance(color: Rgb): number { + const toLinear = (channel: number) => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); + }; + return ( + 0.2126 * toLinear(color.r) + + 0.7152 * toLinear(color.g) + + 0.0722 * toLinear(color.b) + ); +} + +function contrastRatio(a: Rgb, b: Rgb): number { + const l1 = luminance(a); + const l2 = luminance(b); + const [bright, dark] = l1 >= l2 ? [l1, l2] : [l2, l1]; + return (bright + 0.05) / (dark + 0.05); +} + +function pickReadableText(background: string, preferred: string): string { + const bg = parseColor(background); + const pref = parseColor(preferred); + const dark = "#0f172a"; + const light = "#f8fafc"; + const darkRgb = parseColor(dark)!; + const lightRgb = parseColor(light)!; + + if (!bg) { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? light + : dark; + } + if (pref && contrastRatio(bg, pref) >= 4.5) return preferred; + return contrastRatio(bg, darkRgb) >= contrastRatio(bg, lightRgb) + ? dark + : light; +} + +function pickBorderColor(background: string): string { + const bg = parseColor(background); + if (!bg) return "rgba(15, 23, 42, 0.18)"; + return luminance(bg) >= 0.6 + ? "rgba(15, 23, 42, 0.18)" + : "rgba(248, 250, 252, 0.22)"; +} + export const ThemeProvider: React.FC = ({ children }) => { const { colors: themeColors } = useThemeColors(); // Apply theme colors to CSS variables useEffect(() => { - if (themeColors.kColorSysBase) { - document.documentElement.style.setProperty( - "--chrome-bg-primary", - themeColors.kColorSysBase - ); - } - if (themeColors.kColorSysOnSurface) { - document.documentElement.style.setProperty( - "--chrome-text-primary", - themeColors.kColorSysOnSurface - ); - document.documentElement.style.setProperty( - "--chrome-icon-color", - themeColors.kColorSysOnSurface - ); - } - if (themeColors.kColorSysBaseContainer) { - document.documentElement.style.setProperty( - "--chrome-input-background", - themeColors.kColorSysBaseContainer - ); - document.documentElement.style.setProperty( - "--chrome-input-border", - themeColors.kColorSysBaseContainer - ); - } + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)" + ).matches; + const base = + themeColors.kColorSysBase || (prefersDark ? "#1f1f1f" : "#ffffff"); + const inputBackground = + themeColors.kColorSysBaseContainer || + (prefersDark ? "#2a2b2e" : "#ffffff"); + const preferredText = + themeColors.kColorSysOnSurface || (prefersDark ? "#ffffff" : "#000000"); + const readableText = pickReadableText(inputBackground, preferredText); + const borderColor = + themeColors.kColorSysTonalContainer || pickBorderColor(inputBackground); + const secondary = + themeColors.kColorSysSurface || + (prefersDark ? "rgba(31, 31, 31, 0.94)" : "rgba(245, 245, 245, 0.96)"); + + document.documentElement.style.setProperty("--chrome-bg-primary", base); + document.documentElement.style.setProperty( + "--chrome-bg-secondary", + secondary + ); + document.documentElement.style.setProperty( + "--chrome-input-background", + inputBackground + ); + document.documentElement.style.setProperty( + "--chrome-text-primary", + readableText + ); + document.documentElement.style.setProperty( + "--chrome-icon-color", + readableText + ); + document.documentElement.style.setProperty( + "--chrome-input-border", + borderColor + ); }, [themeColors]); return <>{children};