diff --git a/packages/connectors/src/browser/evaluate.ts b/packages/connectors/src/browser/evaluate.ts deleted file mode 100644 index ee16079ff..000000000 --- a/packages/connectors/src/browser/evaluate.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Browser Evaluate Connector — Owletto for Chrome only. - * - * Runs on the Owletto Chrome extension, which advertises capability - * `browser.debugger`. The extension attaches `chrome.debugger` to a tab, - * optionally navigates + waits for a selector, runs the supplied JS via - * `Runtime.evaluate`, and emits one event with the JSON-serialised result. - * - * This is the generic "agent runs JS in a user's signed-in Chrome" primitive - * — most bridge connectors (Revolut feed, banking, sites that fingerprint - * a managed Chromium) compose on top of `browser.evaluate` rather than - * shipping their own connector. The trust boundary is `config.script`: only - * the gateway-side connector author should mint it. The extension defaults - * to opening a fresh background tab so a compromised gateway / leaked token - * can't drive the tab a user is actively using; see executor.js in - * owletto-web for the full threat model. - * - * Cloud-side `sync()` / `execute()` throw — actual work happens in the - * extension's service worker (lobu-ai/owletto: apps/chrome/executor.js). - */ - -import { - type ActionResult, - type ConnectorDefinition, - ConnectorRuntime, - type SyncContext, - type SyncResult, -} from '@lobu/connector-sdk'; - -const BRIDGE_ONLY = - 'browser.evaluate runs only on a worker advertising capability "browser.debugger" (Owletto for Chrome).'; - -export default class BrowserEvaluateConnector extends ConnectorRuntime { - readonly definition: ConnectorDefinition = { - key: 'browser.evaluate', - name: 'Browser Evaluate', - description: - 'Runs a JS snippet in a page via chrome.debugger and emits the result. The primitive most bridge connectors build on.', - version: '0.1.0', - faviconDomain: 'google.com', - requiredCapability: 'browser.debugger', - runtime: { platforms: ['chrome-extension'] }, - authSchema: { methods: [{ type: 'none' }] }, - feeds: { - evaluate: { - key: 'evaluate', - name: 'Evaluate JS', - description: - 'Executes a JS expression in the page and emits one event with the JSON-serialised return value.', - // `script` is required and gateway-author-supplied. Auto-wire would - // insert a feed row with config=NULL and produce a runs-but-fails - // loop. Bridge connectors (Revolut, banking, etc.) compose by - // creating explicit feed instances per call site. - userManaged: true, - configSchema: { - type: 'object', - required: ['script'], - properties: { - url: { - type: 'string', - format: 'uri', - description: 'If set, navigate the tab here before evaluating.', - }, - script: { - type: 'string', - description: - 'JS expression evaluated with Runtime.evaluate(awaitPromise: true). Return value is JSON-serialised — keep it small.', - }, - wait_for_selector: { - type: 'string', - description: - 'CSS selector to wait for before evaluating (polled every 200ms via Runtime.evaluate).', - }, - wait_timeout_ms: { - type: 'integer', - minimum: 100, - maximum: 60_000, - description: 'Timeout for wait_for_selector. Default 10000.', - }, - open_in_new_tab: { - type: 'boolean', - description: - 'Open a fresh background tab instead of driving the active tab. DEFAULT TRUE — opt out only when you specifically need the user-active tab.', - }, - close_tab_after: { - type: 'boolean', - description: - 'Close the tab when the run completes. Defaults to true when open_in_new_tab is true.', - }, - }, - }, - eventKinds: { - browser_evaluate: { - description: - 'One event per run with the JSON-serialised Runtime.evaluate result.', - metadataSchema: { - type: 'object', - required: ['source', 'origin_id'], - properties: { - source: { type: 'string', const: 'browser_evaluate' }, - origin_id: { type: 'string' }, - url: { type: 'string' }, - title: { type: 'string' }, - tab_id: { type: 'integer' }, - }, - }, - }, - }, - }, - }, - }; - - async sync(_ctx: SyncContext): Promise { - throw new Error(BRIDGE_ONLY); - } - - async execute(): Promise { - throw new Error(BRIDGE_ONLY); - } -} diff --git a/packages/connectors/src/browser/fill_form.ts b/packages/connectors/src/browser/fill_form.ts deleted file mode 100644 index 9854afc32..000000000 --- a/packages/connectors/src/browser/fill_form.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Browser Fill Form Connector — Owletto for Chrome only. - * - * Thin wrapper around browser.evaluate that bakes in a "fill these inputs - * by selector and dispatch the right input/change events" script. - * - * The extension's executor branch for `browser.fill_form` substitutes the - * canonical fill-form script when this connector_key is dispatched. The - * server-side definition just exposes the URL + fields config to the - * admin UI. - * - * Cloud-side `sync()` / `execute()` throw — actual work happens in the - * extension's service worker (lobu-ai/owletto: apps/chrome/executor.js). - */ - -import { - type ActionResult, - type ConnectorDefinition, - ConnectorRuntime, - type SyncContext, - type SyncResult, -} from '@lobu/connector-sdk'; - -const BRIDGE_ONLY = - 'browser.fill_form runs only on a worker advertising capability "browser.debugger" (Owletto for Chrome).'; - -export default class BrowserFillFormConnector extends ConnectorRuntime { - readonly definition: ConnectorDefinition = { - key: 'browser.fill_form', - name: 'Browser Fill Form', - description: - 'Fills inputs on a page by CSS selector and dispatches input/change events. Returns the filled field count.', - version: '0.1.0', - faviconDomain: 'google.com', - requiredCapability: 'browser.debugger', - runtime: { platforms: ['chrome-extension'] }, - authSchema: { methods: [{ type: 'none' }] }, - feeds: { - fill: { - key: 'fill', - name: 'Fill form', - description: - 'Sets values on input/textarea/select elements matched by CSS selector.', - // Required url + fields; instances are minted by composing bridge - // connectors, not auto-wired by device-reconcile. - userManaged: true, - configSchema: { - type: 'object', - required: ['url', 'fields'], - properties: { - url: { - type: 'string', - format: 'uri', - description: 'Page to load before filling.', - }, - fields: { - type: 'object', - description: - 'Map of CSS selector → value to set. e.g. { "#email": "x@y.com", "#submit": "click" } — the literal string "click" triggers a click instead of a value set.', - additionalProperties: { type: 'string' }, - }, - wait_for_selector: { - type: 'string', - description: - 'CSS selector to wait for before filling (defaults to the first key of fields).', - }, - wait_timeout_ms: { - type: 'integer', - minimum: 100, - maximum: 60_000, - }, - submit_selector: { - type: 'string', - description: - 'Optional selector to click after filling all fields (e.g. "button[type=submit]").', - }, - }, - }, - eventKinds: { - form_filled: { - description: - 'One event per run with the count of fields filled + whether submit was clicked.', - metadataSchema: { - type: 'object', - required: ['source', 'origin_id', 'url', 'filled_count'], - properties: { - source: { type: 'string', const: 'browser_fill_form' }, - origin_id: { type: 'string' }, - url: { type: 'string', format: 'uri' }, - filled_count: { type: 'integer' }, - submitted: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - }; - - async sync(_ctx: SyncContext): Promise { - throw new Error(BRIDGE_ONLY); - } - - async execute(): Promise { - throw new Error(BRIDGE_ONLY); - } -} diff --git a/packages/connectors/src/browser/page_text.ts b/packages/connectors/src/browser/page_text.ts deleted file mode 100644 index 1f7c72a8e..000000000 --- a/packages/connectors/src/browser/page_text.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Browser Page Text Connector — Owletto for Chrome only. - * - * Thin wrapper around browser.evaluate that bakes in a "return cleaned-up - * page text" script. Saves the connector author from re-deriving the - * text-extraction recipe for every page-scrape feed. - * - * The extension's executor branch for `browser.page_text` is responsible - * for substituting the canonical script when this connector_key is - * dispatched — gateway-side this connector definition just exposes the - * URL + selector-scope config to the admin UI. - * - * Cloud-side `sync()` / `execute()` throw — actual work happens in the - * extension's service worker (lobu-ai/owletto: apps/chrome/executor.js). - */ - -import { - type ActionResult, - type ConnectorDefinition, - ConnectorRuntime, - type SyncContext, - type SyncResult, -} from '@lobu/connector-sdk'; - -const BRIDGE_ONLY = - 'browser.page_text runs only on a worker advertising capability "browser.debugger" (Owletto for Chrome).'; - -export default class BrowserPageTextConnector extends ConnectorRuntime { - readonly definition: ConnectorDefinition = { - key: 'browser.page_text', - name: 'Browser Page Text', - description: - 'Fetches a page in the paired Chrome and returns its readable text content. Wraps browser.evaluate with a canonical text-extraction script.', - version: '0.1.0', - faviconDomain: 'google.com', - requiredCapability: 'browser.debugger', - runtime: { platforms: ['chrome-extension'] }, - authSchema: { methods: [{ type: 'none' }] }, - feeds: { - page: { - key: 'page', - name: 'Page text', - description: 'Snapshot of the text content of a single page.', - // Required url; instances are minted by composing bridge connectors, - // not auto-wired by device-reconcile. - userManaged: true, - configSchema: { - type: 'object', - required: ['url'], - properties: { - url: { - type: 'string', - format: 'uri', - description: 'Page to load and read text from.', - }, - selector: { - type: 'string', - description: - 'CSS selector to scope the extraction to (defaults to body.innerText).', - }, - wait_for_selector: { - type: 'string', - description: - 'CSS selector to wait for before reading (defaults to body).', - }, - wait_timeout_ms: { - type: 'integer', - minimum: 100, - maximum: 60_000, - }, - max_chars: { - type: 'integer', - minimum: 100, - maximum: 1_000_000, - description: 'Truncate output past this length. Default 200000.', - }, - }, - }, - eventKinds: { - page_text: { - description: - 'One event per run containing the page text (truncated to max_chars).', - metadataSchema: { - type: 'object', - required: ['source', 'origin_id', 'url'], - properties: { - source: { type: 'string', const: 'browser_page_text' }, - origin_id: { type: 'string' }, - url: { type: 'string', format: 'uri' }, - title: { type: 'string' }, - char_count: { type: 'integer' }, - truncated: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - }; - - async sync(_ctx: SyncContext): Promise { - throw new Error(BRIDGE_ONLY); - } - - async execute(): Promise { - throw new Error(BRIDGE_ONLY); - } -} diff --git a/packages/connectors/src/chrome.ts b/packages/connectors/src/chrome.ts new file mode 100644 index 000000000..528c917c2 --- /dev/null +++ b/packages/connectors/src/chrome.ts @@ -0,0 +1,351 @@ +/** + * Chrome Connector — Owletto for Chrome only. + * + * One connector per paired Chrome profile. The cloud-side definition is + * pure metadata; all execution happens in the extension's service worker + * (apps/chrome/background.js: dispatchToolRun) against the user's signed-in + * Chrome via chrome.debugger + chrome.scripting + chrome.tabs. + * + * Surface: + * + * feeds.open_tabs + * Auto-wired snapshot feed. The extension emits one event per open tab + * each sync cycle. Cheap and read-only. + * + * actions.navigate + * Page.navigate the target tab (default: a fresh background tab; opt + * out via open_in_new_tab=false) to `url`. + * + * actions.get_accessibility_tree + * Inject the bundled accessibility-tree.js content script and return a + * structured snapshot of the visible interactive nodes, each with a + * stable {frame_id, document_epoch, ref_id} that subsequent + * click_ref/type_ref calls can target. Sensitive fields (password, + * one-time-code, credit-card autocomplete) are redacted in the page + * before the snapshot leaves it. + * + * actions.click_ref / actions.type_ref + * Act on a ref returned by get_accessibility_tree, in the same tab, + * using chrome.debugger Input.dispatchMouseEvent / dispatchKeyEvent / + * insertText. Refs become stale on navigation or DOM replacement; the + * extension surfaces a clear error and the caller re-snapshots. + * + * actions.wait_for_selector + * Poll the page for a CSS selector via Runtime.evaluate. Returns when + * it appears or rejects on timeout (default 10s). + * + * actions.screenshot + * Page.captureScreenshot. PNG, base64-encoded. + * + * actions.evaluate + * Runtime.evaluate(expression). Returns the JSON-serialised result. + * Last-resort escape hatch — prefer ref-based actions because the + * script string is harder to audit. + * + * The connector author writes a normal server-side sync() that sequences + * these actions through `ctx.chrome.(args)` (helper added in a + * later PR — for v1 the actions are reachable directly via the run + * scheduling API). No bespoke executor code lives in the extension; new + * connectors compose existing tools. + * + * URL allowlist: each connector that runs on top of this dispatcher + * declares `allowedOrigins` on its own definition. The extension refuses + * any tool call whose target URL is outside the allowlist. + * + * Required worker capability is `browser.debugger`. + * + * Cloud-side `sync()` / `execute()` throw — actual work happens in the + * extension's service worker. + */ + +import { + type ActionResult, + type ConnectorDefinition, + ConnectorRuntime, + type SyncContext, + type SyncResult, +} from '@lobu/connector-sdk'; + +const BRIDGE_ONLY = + 'chrome runs only on a worker advertising capability "browser.debugger" (Owletto for Chrome).'; + +const tabIdSchema = { + type: 'integer', + description: 'Tab to act on. Defaults to the run-scoped scratch tab.', +} as const; + +const refIdSchema = { + type: 'object', + required: ['document_epoch', 'ref_id'], + properties: { + document_epoch: { type: 'integer' }, + ref_id: { type: 'integer' }, + }, + description: + 'Element reference returned by a prior get_accessibility_tree call on the same tab + document. frame_id is reserved for future iframe support; v1 dispatches against the main frame.', +} as const; + +export default class ChromeConnector extends ConnectorRuntime { + readonly definition: ConnectorDefinition = { + key: 'chrome', + name: 'Chrome', + description: + 'Paired Chrome profile. Tab snapshots + a fixed set of typed browser actions (navigate, click, type, wait, screenshot, accessibility snapshot, evaluate) that connectors compose without shipping per-connector code into the extension.', + version: '0.2.0', + faviconDomain: 'google.com', + requiredCapability: 'browser.debugger', + runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] }, + authSchema: { methods: [{ type: 'none' }] }, + feeds: { + open_tabs: { + key: 'open_tabs', + name: 'Open tabs', + description: 'Snapshot of the tabs currently open in this Chrome profile.', + configSchema: { type: 'object', properties: {} }, + eventKinds: { + tab_snapshot: { + description: 'One row per tab observed in the active poll cycle.', + metadataSchema: { + type: 'object', + required: ['source', 'origin_id', 'url'], + properties: { + source: { type: 'string', const: 'chrome_tabs' }, + origin_id: { type: 'string' }, + url: { type: 'string', format: 'uri' }, + title: { type: 'string' }, + window_id: { type: 'integer' }, + active: { type: 'boolean' }, + }, + }, + }, + }, + }, + tab_events: { + key: 'tab_events', + name: 'Tab events', + description: + 'Live stream of tab creates / closes / URL changes / focus changes. Each event has a timestamp, so this is the lossless "browsing timeline" companion to the open_tabs snapshot. No extra permission required (baseline `tabs`).', + configSchema: { type: 'object', properties: {} }, + eventKinds: { + tab_event: { + description: + 'One row per tab lifecycle event. event_type is one of: created, removed, updated, activated.', + metadataSchema: { + type: 'object', + required: ['source', 'origin_id', 'event_type'], + properties: { + source: { type: 'string', const: 'chrome_tab_events' }, + origin_id: { type: 'string' }, + event_type: { + enum: ['created', 'removed', 'updated', 'activated'], + }, + tab_id: { type: 'integer' }, + url: { type: 'string' }, + title: { type: 'string' }, + window_id: { type: 'integer' }, + from_url: { + type: 'string', + description: 'For updated events, the URL the tab was on before the change.', + }, + }, + }, + }, + }, + }, + }, + actions: { + navigate: { + key: 'navigate', + name: 'Navigate', + description: 'Open a URL in a fresh background tab (default) or an existing tab.', + requiresApproval: false, + inputSchema: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', format: 'uri' }, + tab_id: tabIdSchema, + open_in_new_tab: { + type: 'boolean', + description: 'Default true. Opt out for active-tab control.', + }, + wait_for_load: { + type: 'boolean', + description: + 'Wait for Page.frameStoppedLoading on the main frame before returning. Default true.', + }, + }, + }, + outputSchema: { + type: 'object', + properties: { + tab_id: { type: 'integer' }, + current_url: { type: 'string' }, + title: { type: 'string' }, + }, + }, + }, + get_accessibility_tree: { + key: 'get_accessibility_tree', + name: 'Get accessibility tree', + description: + 'Return a structured snapshot of the visible interactive elements on the page, with stable refs for click_ref/type_ref. Sensitive fields are redacted.', + requiresApproval: false, + inputSchema: { + type: 'object', + properties: { + tab_id: tabIdSchema, + filter: { + enum: ['interactive', 'visible', 'all'], + description: 'Default "interactive". "all" is for debugging only.', + }, + }, + }, + outputSchema: { + type: 'object', + properties: { + document_epoch: { type: 'integer' }, + current_url: { type: 'string' }, + title: { type: 'string' }, + tree: { type: 'array' }, + }, + }, + }, + click_ref: { + key: 'click_ref', + name: 'Click element by ref', + description: + 'Dispatch a mouse click on the element identified by a ref from a prior accessibility snapshot of the same tab + document.', + requiresApproval: false, + inputSchema: { + type: 'object', + required: ['ref'], + properties: { + ref: refIdSchema, + tab_id: tabIdSchema, + button: { + enum: ['left', 'right', 'middle'], + description: 'Default "left".', + }, + click_count: { + type: 'integer', + minimum: 1, + maximum: 3, + description: 'Default 1. Use 2 for double-click, 3 for triple-click.', + }, + }, + }, + }, + type_ref: { + key: 'type_ref', + name: 'Type into element by ref', + description: + 'Focus the element identified by a ref and dispatch keystrokes to enter the given text. Existing value is replaced by default.', + requiresApproval: false, + inputSchema: { + type: 'object', + required: ['ref', 'text'], + properties: { + ref: refIdSchema, + tab_id: tabIdSchema, + text: { type: 'string' }, + clear_first: { + type: 'boolean', + description: 'Default true. Selects all + deletes before typing.', + }, + }, + }, + }, + wait_for_selector: { + key: 'wait_for_selector', + name: 'Wait for selector', + description: + 'Poll the page for the first match of a CSS selector and return when it appears.', + requiresApproval: false, + inputSchema: { + type: 'object', + required: ['selector'], + properties: { + selector: { type: 'string' }, + tab_id: tabIdSchema, + timeout_ms: { + type: 'integer', + minimum: 100, + maximum: 60_000, + description: 'Default 10000.', + }, + }, + }, + }, + screenshot: { + key: 'screenshot', + name: 'Screenshot', + description: 'Capture the visible viewport as a PNG.', + requiresApproval: false, + inputSchema: { + type: 'object', + properties: { + tab_id: tabIdSchema, + }, + }, + outputSchema: { + type: 'object', + properties: { + data_url: { + type: 'string', + description: 'data:image/png;base64,... — caller decodes.', + }, + width: { type: 'integer' }, + height: { type: 'integer' }, + }, + }, + }, + close_tab: { + key: 'close_tab', + name: 'Close tab', + description: + 'Close a tab the extension created for this connector. Required at the end of any multi-step session — tabs the extension owned across navigate / get_accessibility_tree / click_ref / etc. are NOT auto-disposed (that would break the natural flow). A reaper closes orphaned owned tabs after 30 minutes.', + requiresApproval: false, + inputSchema: { + type: 'object', + required: ['tab_id'], + properties: { tab_id: { type: 'integer' } }, + }, + }, + evaluate: { + key: 'evaluate', + name: 'Evaluate JS', + description: + 'Last-resort escape hatch: run a JS expression with Runtime.evaluate and return the JSON-serialised result. Prefer ref-based actions when possible — scripts are harder to audit.', + requiresApproval: false, + inputSchema: { + type: 'object', + required: ['expression'], + properties: { + expression: { type: 'string' }, + tab_id: tabIdSchema, + await_promise: { + type: 'boolean', + description: 'Default true.', + }, + }, + }, + outputSchema: { + type: 'object', + properties: { + value: {}, + exception: { type: 'string' }, + }, + }, + }, + }, + }; + + async sync(_ctx: SyncContext): Promise { + throw new Error(BRIDGE_ONLY); + } + + async execute(): Promise { + throw new Error(BRIDGE_ONLY); + } +} diff --git a/packages/connectors/src/chrome_bookmarks.ts b/packages/connectors/src/chrome_bookmarks.ts new file mode 100644 index 000000000..45ec9eaf9 --- /dev/null +++ b/packages/connectors/src/chrome_bookmarks.ts @@ -0,0 +1,79 @@ +/** + * Chrome Bookmarks Connector — Owletto for Chrome only. + * + * Opt-in ambient feed. The Chrome extension advertises capability + * `browser.bookmarks` when the user grants the `bookmarks` Chrome + * permission via the sidepanel Permissions panel; the gateway then + * auto-wires this connector. + * + * Emits one event per bookmark (with its folder path). Backfills the + * full tree on first sync, then streams `bookmarks.onCreated/onRemoved/ + * onChanged/onMoved` thereafter. + * + * Cloud-side `sync()` / `execute()` throw — actual work happens in + * apps/chrome/feeds-bookmarks.js in the extension. + */ + +import { + type ActionResult, + type ConnectorDefinition, + ConnectorRuntime, + type SyncContext, + type SyncResult, +} from '@lobu/connector-sdk'; + +const BRIDGE_ONLY = + 'chrome.bookmarks runs only on a worker advertising capability "browser.bookmarks" (Owletto for Chrome with bookmarks permission granted).'; + +export default class ChromeBookmarksConnector extends ConnectorRuntime { + readonly definition: ConnectorDefinition = { + key: 'chrome.bookmarks', + name: 'Chrome bookmarks', + description: + "Bookmarks (and folder structure) from the paired Chrome profile. Opt-in — requires the user to grant the extension's optional `bookmarks` permission.", + version: '0.1.0', + faviconDomain: 'google.com', + requiredCapability: 'browser.bookmarks', + runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] }, + authSchema: { methods: [{ type: 'none' }] }, + feeds: { + bookmarks: { + key: 'bookmarks', + name: 'Bookmarks', + description: + 'One event per bookmark. Backfills the full tree on first sync via chrome.bookmarks.getTree, then streams onCreated / onRemoved / onChanged / onMoved.', + configSchema: { type: 'object', properties: {} }, + eventKinds: { + bookmark: { + description: 'One row per bookmark add/remove/edit.', + metadataSchema: { + type: 'object', + required: ['source', 'origin_id', 'event_type'], + properties: { + source: { type: 'string', const: 'chrome_bookmarks' }, + origin_id: { type: 'string' }, + event_type: { + enum: ['created', 'removed', 'changed', 'moved'], + }, + bookmark_id: { type: 'string' }, + title: { type: 'string' }, + url: { type: 'string' }, + parent_folder_id: { type: 'string' }, + parent_folder_path: { type: 'string' }, + date_added: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }; + + async sync(_ctx: SyncContext): Promise { + throw new Error(BRIDGE_ONLY); + } + + async execute(): Promise { + throw new Error(BRIDGE_ONLY); + } +} diff --git a/packages/connectors/src/chrome_downloads.ts b/packages/connectors/src/chrome_downloads.ts new file mode 100644 index 000000000..a63c3d452 --- /dev/null +++ b/packages/connectors/src/chrome_downloads.ts @@ -0,0 +1,80 @@ +/** + * Chrome Downloads Connector — Owletto for Chrome only. + * + * Opt-in ambient feed. The Chrome extension advertises capability + * `browser.downloads` when the user grants the `downloads` Chrome + * permission via the sidepanel Permissions panel; the gateway then + * auto-wires this connector. + * + * Emits one event per download (filename, source URL, mime type, size, + * finish time). Backfills recent downloads on first sync via + * chrome.downloads.search({}), then streams chrome.downloads.onCreated / + * onChanged. + * + * Cloud-side `sync()` / `execute()` throw — actual work happens in + * apps/chrome/feeds-downloads.js in the extension. + */ + +import { + type ActionResult, + type ConnectorDefinition, + ConnectorRuntime, + type SyncContext, + type SyncResult, +} from '@lobu/connector-sdk'; + +const BRIDGE_ONLY = + 'chrome.downloads runs only on a worker advertising capability "browser.downloads" (Owletto for Chrome with downloads permission granted).'; + +export default class ChromeDownloadsConnector extends ConnectorRuntime { + readonly definition: ConnectorDefinition = { + key: 'chrome.downloads', + name: 'Chrome downloads', + description: + "Files downloaded in the paired Chrome profile, with their source URLs. Opt-in — requires the user to grant the extension's optional `downloads` permission.", + version: '0.1.0', + faviconDomain: 'google.com', + requiredCapability: 'browser.downloads', + runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] }, + authSchema: { methods: [{ type: 'none' }] }, + feeds: { + downloads: { + key: 'downloads', + name: 'Downloads', + description: + 'One event per download. Backfills via chrome.downloads.search({}), then streams onCreated / onChanged (state=complete).', + configSchema: { type: 'object', properties: {} }, + eventKinds: { + download: { + description: 'One row per file the user downloaded.', + metadataSchema: { + type: 'object', + required: ['source', 'origin_id'], + properties: { + source: { type: 'string', const: 'chrome_downloads' }, + origin_id: { type: 'string' }, + download_id: { type: 'integer' }, + filename: { type: 'string' }, + source_url: { type: 'string', format: 'uri' }, + referrer: { type: 'string' }, + mime: { type: 'string' }, + bytes: { type: 'integer' }, + started_at: { type: 'string', format: 'date-time' }, + finished_at: { type: 'string', format: 'date-time' }, + state: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }; + + async sync(_ctx: SyncContext): Promise { + throw new Error(BRIDGE_ONLY); + } + + async execute(): Promise { + throw new Error(BRIDGE_ONLY); + } +} diff --git a/packages/connectors/src/chrome_history.ts b/packages/connectors/src/chrome_history.ts new file mode 100644 index 000000000..5393652db --- /dev/null +++ b/packages/connectors/src/chrome_history.ts @@ -0,0 +1,80 @@ +/** + * Chrome History Connector — Owletto for Chrome only. + * + * Opt-in ambient feed. The Chrome extension advertises capability + * `browser.history` when the user grants the `history` Chrome permission + * via the sidepanel Permissions panel; the gateway then auto-wires this + * connector to the paired chrome-extension device. + * + * Emits one event per page load (URL, title, visit time, transition type). + * The backfill feed seeds with up to 90 days of history on first sync; the + * live feed streams `history.onVisited` thereafter. + * + * Cloud-side `sync()` / `execute()` throw — actual work happens in + * apps/chrome/feeds-history.js in the extension. + */ + +import { + type ActionResult, + type ConnectorDefinition, + ConnectorRuntime, + type SyncContext, + type SyncResult, +} from '@lobu/connector-sdk'; + +const BRIDGE_ONLY = + 'chrome.history runs only on a worker advertising capability "browser.history" (Owletto for Chrome with history permission granted).'; + +export default class ChromeHistoryConnector extends ConnectorRuntime { + readonly definition: ConnectorDefinition = { + key: 'chrome.history', + name: 'Chrome history', + description: + "Every page the user visits in their paired Chrome profile, with visit time + transition type. Opt-in — requires the user to grant the extension's optional `history` permission.", + version: '0.1.0', + faviconDomain: 'google.com', + requiredCapability: 'browser.history', + runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] }, + authSchema: { methods: [{ type: 'none' }] }, + feeds: { + visits: { + key: 'visits', + name: 'Visits', + description: + 'One event per page visit. Backfills ~90 days on first sync (chrome.history.search), then streams new visits via the chrome.history.onVisited listener.', + configSchema: { type: 'object', properties: {} }, + eventKinds: { + page_visit: { + description: 'One row per visit observed.', + metadataSchema: { + type: 'object', + required: ['source', 'origin_id', 'url', 'visit_time'], + properties: { + source: { type: 'string', const: 'chrome_history' }, + origin_id: { type: 'string' }, + url: { type: 'string', format: 'uri' }, + title: { type: 'string' }, + visit_time: { type: 'string', format: 'date-time' }, + transition_type: { + description: + 'How the user got to the page: link, typed, auto_bookmark, auto_subframe, manual_subframe, generated, start_page, form_submit, reload, keyword, keyword_generated.', + type: 'string', + }, + visit_id: { type: 'integer' }, + visit_count: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }; + + async sync(_ctx: SyncContext): Promise { + throw new Error(BRIDGE_ONLY); + } + + async execute(): Promise { + throw new Error(BRIDGE_ONLY); + } +} diff --git a/packages/connectors/src/chrome_tabs.ts b/packages/connectors/src/chrome_tabs.ts deleted file mode 100644 index 71a1766fe..000000000 --- a/packages/connectors/src/chrome_tabs.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Chrome Tabs Connector — Owletto for Chrome only. - * - * Runs on the Owletto Chrome extension, which advertises capability - * `browser.tabs`. The extension uses `chrome.tabs.query()` to list the tabs - * currently open in the user's paired Chrome profile. No persistent backfill - * — each sync returns the live tab list at that moment. - * - * This connector is the smallest end-to-end demo of the Chrome-extension - * device protocol: it proves a connector definition can declare a browser - * capability, get auto-wired into the user's personal org when a - * `chrome-extension` device polls, and route runs to it. - * - * The cloud-side `sync()` / `execute()` throw — actual work happens in the - * extension's service worker (lobu-ai/owletto: apps/chrome/background.js). - */ - -import { - type ActionResult, - type ConnectorDefinition, - ConnectorRuntime, - type SyncContext, - type SyncResult, -} from '@lobu/connector-sdk'; - -const BRIDGE_ONLY = - 'Chrome Tabs runs only on a worker advertising capability "browser.tabs" (Owletto for Chrome).'; - -export default class ChromeTabsConnector extends ConnectorRuntime { - readonly definition: ConnectorDefinition = { - key: 'chrome.tabs', - name: 'Chrome Tabs', - description: - 'Lists the tabs currently open in the paired Chrome profile. Read-only; no history or content.', - version: '0.1.0', - faviconDomain: 'google.com', - requiredCapability: 'browser.tabs', - runtime: { platforms: ['chrome-extension'] }, - authSchema: { methods: [{ type: 'none' }] }, - feeds: { - open_tabs: { - key: 'open_tabs', - name: 'Open tabs', - description: 'Snapshot of the tabs currently open in this Chrome profile.', - configSchema: { type: 'object', properties: {} }, - eventKinds: { - tab_snapshot: { - description: 'One row per tab observed in the active poll cycle.', - metadataSchema: { - type: 'object', - required: ['source', 'origin_id', 'url'], - properties: { - source: { type: 'string', const: 'chrome_tabs' }, - origin_id: { type: 'string' }, - url: { type: 'string', format: 'uri' }, - title: { type: 'string' }, - window_id: { type: 'integer' }, - active: { type: 'boolean' }, - }, - }, - }, - }, - }, - }, - }; - - async sync(_ctx: SyncContext): Promise { - throw new Error(BRIDGE_ONLY); - } - - async execute(): Promise { - throw new Error(BRIDGE_ONLY); - } -} diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts index 6e43a5afc..4533bf0db 100644 --- a/packages/connectors/src/index.ts +++ b/packages/connectors/src/index.ts @@ -3,15 +3,21 @@ export * from './apple_photos.ts'; export * from './apple_screen_time.ts'; export * from './local_directory.ts'; export * from './browser-scraper-utils.ts'; -// Browser primitives — connector definitions whose executors live in the -// Owletto for Chrome extension (apps/chrome/executor.js). Kept under -// browser/ so they're structurally distinct from third-party service -// connectors (linkedin, revolut, github, etc.). -export * from './browser/evaluate.ts'; -export * from './browser/fill_form.ts'; -export * from './browser/page_text.ts'; export * from './capterra.ts'; -export * from './chrome_tabs.ts'; +// Chrome — paired Chrome profile via the Owletto for Chrome extension. +// One connector exposing feeds.open_tabs (auto-wired tab snapshot) + +// actions.{navigate, get_accessibility_tree, click_ref, type_ref, +// wait_for_selector, screenshot, evaluate} (one-shot tools the extension +// dispatcher executes via chrome.debugger + a custom DOM accessibility +// snapshot). Replaces the four prior standalone connectors +// (chrome.tabs / browser.evaluate / browser.page_text / browser.fill_form). +// chrome.history / chrome.bookmarks / chrome.downloads are opt-in +// ambient feeds that auto-wire when the user grants the corresponding +// optional permission in the extension's Permissions panel. +export * from './chrome.ts'; +export * from './chrome_history.ts'; +export * from './chrome_bookmarks.ts'; +export * from './chrome_downloads.ts'; export * from './g2.ts'; export * from './github.ts'; export * from './glassdoor.ts'; diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index e4065b772..139ac0f96 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -25,6 +25,7 @@ export const BROWSER_CAPABILITIES = [ "browser.scripting", "browser.history", "browser.bookmarks", + "browser.downloads", "browser.debugger", // browser.cookies intentionally absent in v1 — high-trust, not approved ] as const; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c47328d48..9c72653e5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -653,6 +653,12 @@ app.use('/api/workers/*', async (c, next) => { '/api/workers/heartbeat', '/api/workers/stream', '/api/workers/complete', + // Action runs (run_type='action') finalize via /complete-action, + // which persists action_output. The handler still goes through + // authorizeRunForWorker so a user worker can only finalize runs + // it claimed. Required for chrome-extension action tools to + // return their observation back to the gateway. + '/api/workers/complete-action', ]); const requestPath = new URL(c.req.url).pathname; const isAuthProfileSubpath = requestPath.startsWith('/api/workers/me/auth-profiles'); diff --git a/packages/server/src/worker-api.ts b/packages/server/src/worker-api.ts index 9a52147e8..309d583be 100644 --- a/packages/server/src/worker-api.ts +++ b/packages/server/src/worker-api.ts @@ -2064,6 +2064,12 @@ export async function completeActionRun(c: Context<{ Bindings: Env }>) { error_message?: string; }>(); + // Same ownership check as the other /complete endpoints — a worker + // can only finalize runs it claimed. Without this, a leaked worker + // token could overwrite action_output on arbitrary runs. + const denied = await authorizeRunForWorker(c, req.run_id, req.worker_id); + if (denied) return denied; + const sql = getDb(); const updatedRuns = await sql`