From 51c0fbf38d9ff4c9e663281bc5bf6ccf3c31ed76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 00:32:03 +0100 Subject: [PATCH 1/6] feat(connectors): browser.evaluate / fill_form / page_text + subdir layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three Chrome-extension connectors (browser.evaluate, browser.fill_form, browser.page_text) whose executors live in the Owletto for Chrome extension. Definitions sit under `packages/connectors/src/browser/` so primitive groupings are structurally distinct from third-party service connectors. - packages/connectors/src/browser/{evaluate,fill_form,page_text}.ts (new) - packages/connectors/src/index.ts: re-export browser/* - packages/connector-worker/src/compile-connector.ts: resolve dotted keys via subdir (`browser/evaluate.ts`) in addition to the existing underscore-flat convention (`chrome_tabs.ts`). - packages/server/src/utils/connector-catalog.ts: scan one level deep so browser/* is discovered; preserve relative `source_path` so resolvers don't collide on basename. Submodule (packages/owletto) is left at main's pin (aeb3324) — the browser.evaluate executor already shipped via #825/#159, so no bump is needed by this PR. --- .../connector-worker/src/compile-connector.ts | 27 ++-- packages/connectors/src/browser/evaluate.ts | 115 ++++++++++++++++++ packages/connectors/src/browser/fill_form.ts | 104 ++++++++++++++++ packages/connectors/src/browser/page_text.ts | 105 ++++++++++++++++ packages/connectors/src/index.ts | 7 ++ .../server/src/utils/connector-catalog.ts | 38 ++++-- 6 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 packages/connectors/src/browser/evaluate.ts create mode 100644 packages/connectors/src/browser/fill_form.ts create mode 100644 packages/connectors/src/browser/page_text.ts diff --git a/packages/connector-worker/src/compile-connector.ts b/packages/connector-worker/src/compile-connector.ts index 0b7d144db..59151b8e5 100644 --- a/packages/connector-worker/src/compile-connector.ts +++ b/packages/connector-worker/src/compile-connector.ts @@ -55,13 +55,26 @@ const CONNECTOR_KEY_RE = /^[a-z][a-z0-9]*(?:[._][a-z0-9]+)*$/; export function findBundledConnectorFile(key: string): string | null { if (!CONNECTOR_KEY_RE.test(key)) return null; - const fileName = `${key.replace(/\./g, '_')}.ts`; - for (const candidate of WORKER_CONNECTOR_DIR_CANDIDATES) { - const filePath = resolve(candidate, fileName); - // Belt-and-braces: assert the resolved path stays under the candidate - // dir even though CONNECTOR_KEY_RE already forbids the dangerous chars. - if (!filePath.startsWith(`${candidate}/`)) continue; - if (existsSync(filePath)) return filePath; + // Two filename conventions: + // - Subdirectory layout (preferred for grouped primitives): the dot in + // `browser.evaluate` maps to `browser/evaluate.ts`. Lets us co-locate + // related connectors without renaming the key. + // - Flat-with-underscores (existing convention): `chrome.tabs` → + // `chrome_tabs.ts`, `apple_health` → `apple_health.ts`. + // Try subdirectory first so newer primitives win when both happen to exist. + const candidates = [ + `${key.replace(/\./g, '/')}.ts`, + `${key.replace(/\./g, '_')}.ts`, + ]; + for (const dir of WORKER_CONNECTOR_DIR_CANDIDATES) { + for (const fileName of candidates) { + const filePath = resolve(dir, fileName); + // Belt-and-braces: the resolved path must stay under the candidate + // dir. CONNECTOR_KEY_RE already forbids `..`, but the regex doesn't + // know about our path-joining choices. + if (!filePath.startsWith(`${dir}/`)) continue; + if (existsSync(filePath)) return filePath; + } } return null; } diff --git a/packages/connectors/src/browser/evaluate.ts b/packages/connectors/src/browser/evaluate.ts new file mode 100644 index 000000000..3cc3a75e7 --- /dev/null +++ b/packages/connectors/src/browser/evaluate.ts @@ -0,0 +1,115 @@ +/** + * 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.', + 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 new file mode 100644 index 000000000..c6ade0c05 --- /dev/null +++ b/packages/connectors/src/browser/fill_form.ts @@ -0,0 +1,104 @@ +/** + * 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.', + 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 new file mode 100644 index 000000000..1acb95fdc --- /dev/null +++ b/packages/connectors/src/browser/page_text.ts @@ -0,0 +1,105 @@ +/** + * 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.', + 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/index.ts b/packages/connectors/src/index.ts index 2c6a49e3e..6e43a5afc 100644 --- a/packages/connectors/src/index.ts +++ b/packages/connectors/src/index.ts @@ -3,6 +3,13 @@ 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'; export * from './g2.ts'; diff --git a/packages/server/src/utils/connector-catalog.ts b/packages/server/src/utils/connector-catalog.ts index 78f64b173..89fd7cc30 100644 --- a/packages/server/src/utils/connector-catalog.ts +++ b/packages/server/src/utils/connector-catalog.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { basename, extname, join, resolve } from 'node:path'; +import { extname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { build, type Plugin } from 'esbuild'; import { EXTERNAL_RUNTIME_DEPS } from '../../../connector-worker/src/runtime-deps'; @@ -349,12 +349,34 @@ export async function listCatalogConnectorDefinitions( continue; } - const entries = await readdir(dirPath, { withFileTypes: true }); - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - if (!entry.isFile()) continue; - if (extname(entry.name) !== '.ts' || entry.name.endsWith('.d.ts')) continue; + // Scan one level deep so primitive groupings like `browser/*.ts` are + // discovered alongside top-level service connectors. Two-level scan + // keeps the loader bounded — connectors don't currently nest deeper. + const candidatePaths: string[] = []; + const topEntries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of topEntries.sort((a, b) => a.name.localeCompare(b.name))) { + const entryPath = resolve(dirPath, entry.name); + if (entry.isFile()) { + if (extname(entry.name) !== '.ts' || entry.name.endsWith('.d.ts')) continue; + candidatePaths.push(entryPath); + continue; + } + if (entry.isDirectory()) { + try { + const subEntries = await readdir(entryPath, { withFileTypes: true }); + for (const sub of subEntries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!sub.isFile()) continue; + if (extname(sub.name) !== '.ts' || sub.name.endsWith('.d.ts')) continue; + candidatePaths.push(resolve(entryPath, sub.name)); + } + } catch { + // Subdir unreadable — skip silently. Top-level scan still produced + // whatever it could; don't fail the whole catalog over one bad dir. + } + } + } - const filePath = resolve(dirPath, entry.name); + for (const filePath of candidatePaths) { const metadata = await extractConnectorCatalogMetadata(filePath); if (!metadata || seenKeys.has(metadata.key)) continue; @@ -373,7 +395,9 @@ export async function listCatalogConnectorDefinitions( runtime: metadata.runtime, status: 'active', login_enabled: metadata.login_enabled, - source_path: basename(filePath), + // Preserve subdirectory in source_path so worker resolvers can + // find `browser/evaluate.ts` etc. without colliding on basename. + source_path: relative(dirPath, filePath), source_uri: pathToFileURL(filePath).toString(), installed: false, installable: true, From 279f66d19a99a85551b0bd834ba94338fad0a113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 00:33:38 +0100 Subject: [PATCH 2/6] chore(format): biome format pre-existing diff.test.ts drift Pre-existing format drift introduced in #829 was failing main's format-lint CI for several commits. Bundling the auto-fix here so PR #828's format-lint check turns green on top of an already-red main. --- .../_lib/apply/__tests__/diff.test.ts | 1894 ++++++++--------- 1 file changed, 947 insertions(+), 947 deletions(-) diff --git a/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts index 549d50d10..e8d79393c 100644 --- a/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts +++ b/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts @@ -10,983 +10,983 @@ import { renderPlan, renderSummary } from "../render.js"; chalk.level = 0; function buildDesiredAgent( - agentId: string, - overrides: Partial = {}, + agentId: string, + overrides: Partial = {} ): DesiredAgent { - return { - metadata: { agentId, name: agentId, description: undefined }, - settings: {}, - platforms: [], - ...overrides, - }; + return { + metadata: { agentId, name: agentId, description: undefined }, + settings: {}, + platforms: [], + ...overrides, + }; } function buildState( - agents: DesiredAgent[], - overrides: Partial = {}, + agents: DesiredAgent[], + overrides: Partial = {} ): DesiredState { - return { - agents, - memorySchema: { entityTypes: [], relationshipTypes: [] }, - watchers: [], - connectors: { definitions: [], authProfiles: [], connections: [] }, - requiredSecrets: [], - ...overrides, - }; + return { + agents, + memorySchema: { entityTypes: [], relationshipTypes: [] }, + watchers: [], + connectors: { definitions: [], authProfiles: [], connections: [] }, + requiredSecrets: [], + ...overrides, + }; } function emptyRemote(): RemoteSnapshot { - return { - agents: [], - agentSettings: new Map(), - platformsByAgent: new Map(), - entityTypes: [], - relationshipTypes: [], - watchers: [], - connectorDefinitions: [], - authProfiles: [], - connections: [], - feedsByConnectionId: new Map(), - }; + return { + agents: [], + agentSettings: new Map(), + platformsByAgent: new Map(), + entityTypes: [], + relationshipTypes: [], + watchers: [], + connectorDefinitions: [], + authProfiles: [], + connections: [], + feedsByConnectionId: new Map(), + }; } describe("apply diff — agents", () => { - test("create from empty remote", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { - agentId: "triage", - name: "Triage", - description: "Triage bot", - }, - }), - ]); - const plan = computeDiff(desired, emptyRemote()); - - expect(plan.counts).toEqual({ create: 2, update: 0, noop: 0, drift: 0 }); - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - test("noop when remote matches desired", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([["triage", null]]), - platformsByAgent: new Map([["triage", []]]), - }; - const plan = computeDiff(desired, remote); - expect(plan.counts.noop).toBeGreaterThan(0); - expect(plan.counts.create).toBe(0); - expect(plan.counts.update).toBe(0); - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - test("update when name differs", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Renamed" }, - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Original" }], - agentSettings: new Map([["triage", null]]), - platformsByAgent: new Map([["triage", []]]), - }; - const plan = computeDiff(desired, remote); - expect(plan.counts.update).toBeGreaterThan(0); - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - test("drift when remote has agent not in desired", () => { - const desired = buildState([]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "stale", name: "Stale Agent" }], - }; - const plan = computeDiff(desired, remote); - expect(plan.counts.drift).toBe(1); - expect(renderPlan(plan)).toMatchSnapshot(); - }); + test("create from empty remote", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { + agentId: "triage", + name: "Triage", + description: "Triage bot", + }, + }), + ]); + const plan = computeDiff(desired, emptyRemote()); + + expect(plan.counts).toEqual({ create: 2, update: 0, noop: 0, drift: 0 }); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("noop when remote matches desired", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([["triage", null]]), + platformsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.noop).toBeGreaterThan(0); + expect(plan.counts.create).toBe(0); + expect(plan.counts.update).toBe(0); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("update when name differs", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Renamed" }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Original" }], + agentSettings: new Map([["triage", null]]), + platformsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.update).toBeGreaterThan(0); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("drift when remote has agent not in desired", () => { + const desired = buildState([]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "stale", name: "Stale Agent" }], + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.drift).toBe(1); + expect(renderPlan(plan)).toMatchSnapshot(); + }); }); describe("apply diff — settings", () => { - test("update on networkConfig change", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - settings: { - networkConfig: { allowedDomains: ["github.com"] }, - }, - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([ - [ - "triage", - { - networkConfig: { allowedDomains: ["pypi.org"] }, - updatedAt: 0, - }, - ], - ]), - platformsByAgent: new Map([["triage", []]]), - }; - const plan = computeDiff(desired, remote); - const settingsRow = plan.rows.find((r) => r.kind === "settings"); - expect(settingsRow?.verb).toBe("update"); - if (settingsRow?.kind === "settings") { - expect(settingsRow.changedFields).toContain("networkConfig"); - } - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - test("updates when provider declarations change but ignores installedAt churn", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - settings: { - installedProviders: [ - { providerId: "anthropic", installedAt: 200 }, - { providerId: "openai", installedAt: 200 }, - ], - }, - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([ - [ - "triage", - { - installedProviders: [{ providerId: "anthropic", installedAt: 100 }], - updatedAt: 0, - }, - ], - ]), - platformsByAgent: new Map([["triage", []]]), - }; - const plan = computeDiff(desired, remote); - const settingsRow = plan.rows.find((r) => r.kind === "settings"); - expect(settingsRow?.verb).toBe("update"); - if (settingsRow?.kind === "settings") { - expect(settingsRow.changedFields).toContain("installedProviders"); - } - - const unchanged = computeDiff(desired, { - ...remote, - agentSettings: new Map([ - [ - "triage", - { - installedProviders: [ - { providerId: "anthropic", installedAt: 1 }, - { providerId: "openai", installedAt: 2 }, - ], - updatedAt: 0, - }, - ], - ]), - }); - const unchangedSettingsRow = unchanged.rows.find( - (r) => r.kind === "settings", - ); - expect(unchangedSettingsRow?.verb).toBe("noop"); - }); + test("update on networkConfig change", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + networkConfig: { allowedDomains: ["github.com"] }, + }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + networkConfig: { allowedDomains: ["pypi.org"] }, + updatedAt: 0, + }, + ], + ]), + platformsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + const settingsRow = plan.rows.find((r) => r.kind === "settings"); + expect(settingsRow?.verb).toBe("update"); + if (settingsRow?.kind === "settings") { + expect(settingsRow.changedFields).toContain("networkConfig"); + } + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("updates when provider declarations change but ignores installedAt churn", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + installedProviders: [ + { providerId: "anthropic", installedAt: 200 }, + { providerId: "openai", installedAt: 200 }, + ], + }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + installedProviders: [{ providerId: "anthropic", installedAt: 100 }], + updatedAt: 0, + }, + ], + ]), + platformsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + const settingsRow = plan.rows.find((r) => r.kind === "settings"); + expect(settingsRow?.verb).toBe("update"); + if (settingsRow?.kind === "settings") { + expect(settingsRow.changedFields).toContain("installedProviders"); + } + + const unchanged = computeDiff(desired, { + ...remote, + agentSettings: new Map([ + [ + "triage", + { + installedProviders: [ + { providerId: "anthropic", installedAt: 1 }, + { providerId: "openai", installedAt: 2 }, + ], + updatedAt: 0, + }, + ], + ]), + }); + const unchangedSettingsRow = unchanged.rows.find( + (r) => r.kind === "settings" + ); + expect(unchangedSettingsRow?.verb).toBe("noop"); + }); }); describe("apply diff — platforms", () => { - test("create on empty remote", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - platforms: [ - { - stableId: "triage-telegram", - type: "telegram", - config: { botToken: "abc" }, - }, - ], - }), - ]); - const plan = computeDiff(desired, emptyRemote()); - const platformRow = plan.rows.find((r) => r.kind === "platform"); - expect(platformRow?.verb).toBe("create"); - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - test("update with willRestart when config changes", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - platforms: [ - { - stableId: "triage-telegram", - type: "telegram", - config: { botToken: "new" }, - }, - ], - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([["triage", null]]), - platformsByAgent: new Map([ - [ - "triage", - [ - { - id: "triage-telegram", - platform: "telegram", - config: { botToken: "old" }, - }, - ], - ], - ]), - }; - const plan = computeDiff(desired, remote); - const platformRow = plan.rows.find((r) => r.kind === "platform"); - expect(platformRow?.verb).toBe("update"); - if (platformRow?.kind === "platform") { - expect(platformRow.willRestart).toBe(true); - } - expect(renderPlan(plan)).toMatchSnapshot(); - }); + test("create on empty remote", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + platforms: [ + { + stableId: "triage-telegram", + type: "telegram", + config: { botToken: "abc" }, + }, + ], + }), + ]); + const plan = computeDiff(desired, emptyRemote()); + const platformRow = plan.rows.find((r) => r.kind === "platform"); + expect(platformRow?.verb).toBe("create"); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("update with willRestart when config changes", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + platforms: [ + { + stableId: "triage-telegram", + type: "telegram", + config: { botToken: "new" }, + }, + ], + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([["triage", null]]), + platformsByAgent: new Map([ + [ + "triage", + [ + { + id: "triage-telegram", + platform: "telegram", + config: { botToken: "old" }, + }, + ], + ], + ]), + }; + const plan = computeDiff(desired, remote); + const platformRow = plan.rows.find((r) => r.kind === "platform"); + expect(platformRow?.verb).toBe("update"); + if (platformRow?.kind === "platform") { + expect(platformRow.willRestart).toBe(true); + } + expect(renderPlan(plan)).toMatchSnapshot(); + }); }); describe("apply diff — memory schema", () => { - test("creates entity + relationship types", () => { - const desired: DesiredState = { - agents: [], - memorySchema: { - entityTypes: [{ slug: "company", name: "Company", required: ["name"] }], - relationshipTypes: [ - { - slug: "works_at", - name: "Works At", - rules: [{ source: "person", target: "company" }], - }, - ], - }, - watchers: [], - requiredSecrets: [], - }; - const plan = computeDiff(desired, emptyRemote()); - expect(plan.counts.create).toBe(2); - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - test("noop when remote matches", () => { - const desired: DesiredState = { - agents: [], - memorySchema: { - entityTypes: [{ slug: "company", name: "Company" }], - relationshipTypes: [], - }, - watchers: [], - requiredSecrets: [], - }; - const remote: RemoteSnapshot = { - ...emptyRemote(), - entityTypes: [{ slug: "company", name: "Company" }], - }; - const plan = computeDiff(desired, remote); - expect(plan.counts.noop).toBe(1); - expect(plan.counts.update).toBe(0); - }); + test("creates entity + relationship types", () => { + const desired: DesiredState = { + agents: [], + memorySchema: { + entityTypes: [{ slug: "company", name: "Company", required: ["name"] }], + relationshipTypes: [ + { + slug: "works_at", + name: "Works At", + rules: [{ source: "person", target: "company" }], + }, + ], + }, + watchers: [], + requiredSecrets: [], + }; + const plan = computeDiff(desired, emptyRemote()); + expect(plan.counts.create).toBe(2); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + test("noop when remote matches", () => { + const desired: DesiredState = { + agents: [], + memorySchema: { + entityTypes: [{ slug: "company", name: "Company" }], + relationshipTypes: [], + }, + watchers: [], + requiredSecrets: [], + }; + const remote: RemoteSnapshot = { + ...emptyRemote(), + entityTypes: [{ slug: "company", name: "Company" }], + }; + const plan = computeDiff(desired, remote); + expect(plan.counts.noop).toBe(1); + expect(plan.counts.update).toBe(0); + }); }); describe("apply diff — empty container preservation", () => { - // Bug fix: previously canonical() collapsed [] and {} to null, which - // meant clearing a remote allowlist by setting it to [] silently - // round-tripped as a noop instead of an update. - test("clearing networkConfig.allowedDomains from non-empty to [] is an update", () => { - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - settings: { - networkConfig: { allowedDomains: [] }, - }, - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([ - [ - "triage", - { - networkConfig: { allowedDomains: ["foo.com"] }, - updatedAt: 0, - }, - ], - ]), - platformsByAgent: new Map([["triage", []]]), - }; - const plan = computeDiff(desired, remote); - const settingsRow = plan.rows.find((r) => r.kind === "settings"); - expect(settingsRow?.verb).toBe("update"); - if (settingsRow?.kind === "settings") { - expect(settingsRow.changedFields).toContain("networkConfig"); - } - }); - - test("[] is not equal to null (preserved as distinct values)", () => { - // When desired sets allowedDomains: [] and remote has the field - // missing entirely, the diff should still treat them as equivalent - // for the case where remote literally doesn't have the field — but - // [] vs the explicit array ["foo"] must differ. - const desiredEmpty = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - settings: { - networkConfig: { allowedDomains: [] }, - }, - }), - ]); - const remoteWithItems: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([ - [ - "triage", - { - networkConfig: { allowedDomains: ["x.com"] }, - updatedAt: 0, - }, - ], - ]), - platformsByAgent: new Map([["triage", []]]), - }; - const plan = computeDiff(desiredEmpty, remoteWithItems); - expect(plan.counts.update).toBeGreaterThan(0); - }); - - test("{} is not equal to populated object", () => { - // empty config object vs populated config object must show as drift/update - const desired = buildState([ - buildDesiredAgent("triage", { - metadata: { agentId: "triage", name: "Triage" }, - platforms: [ - { - stableId: "triage-telegram", - type: "telegram", - config: {}, - }, - ], - }), - ]); - const remote: RemoteSnapshot = { - ...emptyRemote(), - agents: [{ agentId: "triage", name: "Triage" }], - agentSettings: new Map([["triage", null]]), - platformsByAgent: new Map([ - [ - "triage", - [ - { - id: "triage-telegram", - platform: "telegram", - config: { botToken: "abc" }, - }, - ], - ], - ]), - }; - const plan = computeDiff(desired, remote); - const platformRow = plan.rows.find((r) => r.kind === "platform"); - expect(platformRow?.verb).toBe("update"); - }); + // Bug fix: previously canonical() collapsed [] and {} to null, which + // meant clearing a remote allowlist by setting it to [] silently + // round-tripped as a noop instead of an update. + test("clearing networkConfig.allowedDomains from non-empty to [] is an update", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + networkConfig: { allowedDomains: [] }, + }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + networkConfig: { allowedDomains: ["foo.com"] }, + updatedAt: 0, + }, + ], + ]), + platformsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + const settingsRow = plan.rows.find((r) => r.kind === "settings"); + expect(settingsRow?.verb).toBe("update"); + if (settingsRow?.kind === "settings") { + expect(settingsRow.changedFields).toContain("networkConfig"); + } + }); + + test("[] is not equal to null (preserved as distinct values)", () => { + // When desired sets allowedDomains: [] and remote has the field + // missing entirely, the diff should still treat them as equivalent + // for the case where remote literally doesn't have the field — but + // [] vs the explicit array ["foo"] must differ. + const desiredEmpty = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + networkConfig: { allowedDomains: [] }, + }, + }), + ]); + const remoteWithItems: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + networkConfig: { allowedDomains: ["x.com"] }, + updatedAt: 0, + }, + ], + ]), + platformsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desiredEmpty, remoteWithItems); + expect(plan.counts.update).toBeGreaterThan(0); + }); + + test("{} is not equal to populated object", () => { + // empty config object vs populated config object must show as drift/update + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + platforms: [ + { + stableId: "triage-telegram", + type: "telegram", + config: {}, + }, + ], + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([["triage", null]]), + platformsByAgent: new Map([ + [ + "triage", + [ + { + id: "triage-telegram", + platform: "telegram", + config: { botToken: "abc" }, + }, + ], + ], + ]), + }; + const plan = computeDiff(desired, remote); + const platformRow = plan.rows.find((r) => r.kind === "platform"); + expect(platformRow?.verb).toBe("update"); + }); }); describe("apply diff — watchers", () => { - const desiredWatcher = { - slug: "weekly-digest", - agent: "triage", - name: "Weekly digest", - prompt: "Produce a digest.", - extractionSchema: { type: "object" as const }, - schedule: "0 9 * * 1", - }; - - test("create when watcher missing remotely", () => { - const desired = buildState([], { watchers: [desiredWatcher] }); - const plan = computeDiff(desired, emptyRemote()); - const row = plan.rows.find((r) => r.kind === "watcher"); - expect(row?.verb).toBe("create"); - expect(row?.id).toBe("weekly-digest"); - }); - - test("noop when remote matches every field the diff covers", () => { - const desired = buildState([], { watchers: [desiredWatcher] }); - const remote: RemoteSnapshot = { - ...emptyRemote(), - watchers: [ - { - slug: "weekly-digest", - name: "Weekly digest", - agent_id: "triage", - prompt: "Produce a digest.", - extraction_schema: { type: "object" }, - schedule: "0 9 * * 1", - }, - ], - }; - const plan = computeDiff(desired, remote); - const row = plan.rows.find((r) => r.kind === "watcher"); - expect(row?.verb).toBe("noop"); - expect(plan.counts.create).toBe(0); - }); - - test("update with scalar drift when schedule changes remotely", () => { - const desired = buildState([], { watchers: [desiredWatcher] }); - const remote: RemoteSnapshot = { - ...emptyRemote(), - watchers: [ - { - slug: "weekly-digest", - name: "Weekly digest", - agent_id: "triage", - prompt: "Produce a digest.", - extraction_schema: { type: "object" }, - schedule: "0 10 * * 1", - }, - ], - }; - const plan = computeDiff(desired, remote); - const row = plan.rows.find((r) => r.kind === "watcher"); - expect(row?.verb).toBe("update"); - expect(row?.changedFields).toContain("schedule"); - expect( - (row as { versionBoundFields?: string[] }).versionBoundFields, - ).toBeUndefined(); - }); - - test("update with version-bound drift when prompt changes remotely", () => { - const desired = buildState([], { watchers: [desiredWatcher] }); - const remote: RemoteSnapshot = { - ...emptyRemote(), - watchers: [ - { - slug: "weekly-digest", - name: "Weekly digest", - agent_id: "triage", - prompt: "Old prompt", - extraction_schema: { type: "object" }, - schedule: "0 9 * * 1", - }, - ], - }; - const plan = computeDiff(desired, remote); - const row = plan.rows.find((r) => r.kind === "watcher"); - expect(row?.verb).toBe("update"); - expect( - (row as { versionBoundFields?: string[] }).versionBoundFields, - ).toEqual(["prompt"]); - }); - - test("reaction_script declared → always re-pushed (idempotent)", () => { - const desired = buildState([], { - watchers: [ - { - ...desiredWatcher, - reactionScript: { - sourcePath: "/abs/path/r.ts", - sourceCode: "export default async () => {};", - }, - }, - ], - }); - const remote: RemoteSnapshot = { - ...emptyRemote(), - watchers: [ - { - slug: "weekly-digest", - name: "Weekly digest", - agent_id: "triage", - prompt: "Produce a digest.", - extraction_schema: { type: "object" }, - schedule: "0 9 * * 1", - }, - ], - }; - const plan = computeDiff(desired, remote); - const row = plan.rows.find((r) => r.kind === "watcher"); - expect(row?.verb).toBe("update"); - expect(row?.changedFields).toEqual(["reaction_script"]); - expect( - (row as { reactionScriptDeclared?: boolean }).reactionScriptDeclared, - ).toBe(true); - }); - - test("drift when remote watcher not declared in models", () => { - const desired = buildState([], { watchers: [] }); - const remote: RemoteSnapshot = { - ...emptyRemote(), - watchers: [{ slug: "orphan-watcher" }], - }; - const plan = computeDiff(desired, remote); - const row = plan.rows.find((r) => r.kind === "watcher"); - expect(row?.verb).toBe("drift"); - expect(plan.counts.drift).toBe(1); - }); + const desiredWatcher = { + slug: "weekly-digest", + agent: "triage", + name: "Weekly digest", + prompt: "Produce a digest.", + extractionSchema: { type: "object" as const }, + schedule: "0 9 * * 1", + }; + + test("create when watcher missing remotely", () => { + const desired = buildState([], { watchers: [desiredWatcher] }); + const plan = computeDiff(desired, emptyRemote()); + const row = plan.rows.find((r) => r.kind === "watcher"); + expect(row?.verb).toBe("create"); + expect(row?.id).toBe("weekly-digest"); + }); + + test("noop when remote matches every field the diff covers", () => { + const desired = buildState([], { watchers: [desiredWatcher] }); + const remote: RemoteSnapshot = { + ...emptyRemote(), + watchers: [ + { + slug: "weekly-digest", + name: "Weekly digest", + agent_id: "triage", + prompt: "Produce a digest.", + extraction_schema: { type: "object" }, + schedule: "0 9 * * 1", + }, + ], + }; + const plan = computeDiff(desired, remote); + const row = plan.rows.find((r) => r.kind === "watcher"); + expect(row?.verb).toBe("noop"); + expect(plan.counts.create).toBe(0); + }); + + test("update with scalar drift when schedule changes remotely", () => { + const desired = buildState([], { watchers: [desiredWatcher] }); + const remote: RemoteSnapshot = { + ...emptyRemote(), + watchers: [ + { + slug: "weekly-digest", + name: "Weekly digest", + agent_id: "triage", + prompt: "Produce a digest.", + extraction_schema: { type: "object" }, + schedule: "0 10 * * 1", + }, + ], + }; + const plan = computeDiff(desired, remote); + const row = plan.rows.find((r) => r.kind === "watcher"); + expect(row?.verb).toBe("update"); + expect(row?.changedFields).toContain("schedule"); + expect( + (row as { versionBoundFields?: string[] }).versionBoundFields + ).toBeUndefined(); + }); + + test("update with version-bound drift when prompt changes remotely", () => { + const desired = buildState([], { watchers: [desiredWatcher] }); + const remote: RemoteSnapshot = { + ...emptyRemote(), + watchers: [ + { + slug: "weekly-digest", + name: "Weekly digest", + agent_id: "triage", + prompt: "Old prompt", + extraction_schema: { type: "object" }, + schedule: "0 9 * * 1", + }, + ], + }; + const plan = computeDiff(desired, remote); + const row = plan.rows.find((r) => r.kind === "watcher"); + expect(row?.verb).toBe("update"); + expect( + (row as { versionBoundFields?: string[] }).versionBoundFields + ).toEqual(["prompt"]); + }); + + test("reaction_script declared → always re-pushed (idempotent)", () => { + const desired = buildState([], { + watchers: [ + { + ...desiredWatcher, + reactionScript: { + sourcePath: "/abs/path/r.ts", + sourceCode: "export default async () => {};", + }, + }, + ], + }); + const remote: RemoteSnapshot = { + ...emptyRemote(), + watchers: [ + { + slug: "weekly-digest", + name: "Weekly digest", + agent_id: "triage", + prompt: "Produce a digest.", + extraction_schema: { type: "object" }, + schedule: "0 9 * * 1", + }, + ], + }; + const plan = computeDiff(desired, remote); + const row = plan.rows.find((r) => r.kind === "watcher"); + expect(row?.verb).toBe("update"); + expect(row?.changedFields).toEqual(["reaction_script"]); + expect( + (row as { reactionScriptDeclared?: boolean }).reactionScriptDeclared + ).toBe(true); + }); + + test("drift when remote watcher not declared in models", () => { + const desired = buildState([], { watchers: [] }); + const remote: RemoteSnapshot = { + ...emptyRemote(), + watchers: [{ slug: "orphan-watcher" }], + }; + const plan = computeDiff(desired, remote); + const row = plan.rows.find((r) => r.kind === "watcher"); + expect(row?.verb).toBe("drift"); + expect(plan.counts.drift).toBe(1); + }); }); describe("renderSummary", () => { - test("renders zero-row plan", () => { - const desired = buildState([]); - const plan = computeDiff(desired, emptyRemote()); - expect(renderSummary(plan)).toMatchSnapshot(); - }); + test("renders zero-row plan", () => { + const desired = buildState([]); + const plan = computeDiff(desired, emptyRemote()); + expect(renderSummary(plan)).toMatchSnapshot(); + }); }); describe("apply diff — connectors", () => { - const builtinConnectorDef = { - key: "hackernews", - name: "Hacker News", - installed: false, - installable: true, - }; - - function connectorState() { - return buildState([], { - connectors: { - definitions: [ - { - key: "acme", - sourcePath: "/proj/connectors/acme.connector.ts", - sourceCode: "export default class {}", - sourceFile: "connectors/acme.connector.ts", - }, - ], - authProfiles: [ - { - slug: "hn-token", - connector: "hackernews", - kind: "env" as const, - name: "HN token", - credentials: { HN_TOKEN: "$HN_TOKEN" }, - sourceFile: "connectors/hackernews.yaml", - }, - { - slug: "x-account", - connector: "x", - kind: "oauth_account" as const, - sourceFile: "connectors/x.yaml", - }, - ], - connections: [ - { - slug: "hn-frontpage", - connector: "hackernews", - name: "HN front page", - authProfileSlug: "hn-token", - feeds: [{ feedKey: "stories", schedule: "0 * * * *" }], - sourceFile: "connectors/hackernews.yaml", - }, - ], - }, - }); - } - - test("create verbs for new connector def, auth profile, connection, feed", () => { - const plan = computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - }); - const def = plan.rows.find((r) => r.kind === "connector-definition"); - expect(def?.verb).toBe("create"); - const authEnv = plan.rows.find( - (r) => r.kind === "auth-profile" && r.id === "hn-token", - ); - expect(authEnv?.verb).toBe("create"); - const authOauth = plan.rows.find( - (r) => r.kind === "auth-profile" && r.id === "x-account", - ); - expect(authOauth?.verb).toBe("create"); - expect( - authOauth && "needsAuth" in authOauth ? authOauth.needsAuth : undefined, - ).toBe(true); - const conn = plan.rows.find((r) => r.kind === "connection"); - expect(conn?.verb).toBe("create"); - const feed = plan.rows.find((r) => r.kind === "feed"); - expect(feed?.verb).toBe("create"); - expect(feed?.id).toBe("hn-frontpage/stories"); - }); - - test("noop when connection + feed already match remotely", () => { - const remote: RemoteSnapshot = { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - authProfiles: [ - { - slug: "hn-token", - display_name: "HN token", - connector_key: "hackernews", - profile_kind: "env", - status: "active", - }, - { - slug: "x-account", - connector_key: "x", - profile_kind: "oauth_account", - status: "active", - }, - ], - connections: [ - { - id: 7, - slug: "hn-frontpage", - connector_key: "hackernews", - display_name: "HN front page", - status: "active", - auth_profile_slug: "hn-token", - app_auth_profile_slug: null, - config: {}, - }, - ], - feedsByConnectionId: new Map([ - [ - 7, - [ - { - id: 11, - connection_id: 7, - feed_key: "stories", - status: "active", - schedule: "0 * * * *", - config: {}, - }, - ], - ], - ]), - }; - const plan = computeDiff(connectorState(), remote); - expect(plan.rows.find((r) => r.kind === "connection")?.verb).toBe("noop"); - expect(plan.rows.find((r) => r.kind === "feed")?.verb).toBe("noop"); - expect( - plan.rows.find((r) => r.kind === "auth-profile" && r.id === "x-account") - ?.verb, - ).toBe("noop"); - }); - - test("update when feed schedule changes; needs-auth when oauth profile inactive", () => { - const remote: RemoteSnapshot = { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - authProfiles: [ - { - slug: "hn-token", - display_name: "HN token", - connector_key: "hackernews", - profile_kind: "env", - status: "active", - }, - { - slug: "x-account", - connector_key: "x", - profile_kind: "oauth_account", - status: "pending_auth", - }, - ], - connections: [ - { - id: 7, - slug: "hn-frontpage", - connector_key: "hackernews", - display_name: "HN front page", - status: "active", - auth_profile_slug: "hn-token", - app_auth_profile_slug: null, - config: {}, - }, - ], - feedsByConnectionId: new Map([ - [ - 7, - [ - { - id: 11, - connection_id: 7, - feed_key: "stories", - status: "active", - schedule: "0 0 * * *", - config: {}, - }, - ], - ], - ]), - }; - const plan = computeDiff(connectorState(), remote); - const feed = plan.rows.find((r) => r.kind === "feed"); - expect(feed?.verb).toBe("update"); - expect(feed && "changedFields" in feed ? feed.changedFields : []).toEqual([ - "schedule", - ]); - const authOauth = plan.rows.find( - (r) => r.kind === "auth-profile" && r.id === "x-account", - ); - expect( - authOauth && "needsAuth" in authOauth ? authOauth.needsAuth : undefined, - ).toBe(true); - }); - - test("undeclared remote connector becomes an informational note (no uninstall)", () => { - const remote: RemoteSnapshot = { - ...emptyRemote(), - connectorDefinitions: [ - builtinConnectorDef, - { - key: "legacy", - name: "Legacy", - installed: true, - installable: false, - }, - ], - }; - const plan = computeDiff(connectorState(), remote); - expect(plan.notes.some((n) => n.includes('"legacy"'))).toBe(true); - expect( - plan.rows.some( - (r) => r.kind === "connector-definition" && r.id === "legacy", - ), - ).toBe(false); - }); - - test("connectors are skipped when --only is set", () => { - const plan = computeDiff(connectorState(), emptyRemote(), { - only: "agents", - }); - expect(plan.rows.some((r) => r.kind === "connection")).toBe(false); - expect(plan.rows.some((r) => r.kind === "connector-definition")).toBe( - false, - ); - }); - - test("render includes the connectors sections", () => { - const plan = computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - }); - expect(renderPlan(plan)).toMatchSnapshot(); - }); - - // ── round-2 ────────────────────────────────────────────────────────────── - - test("connection slug bound to a different connector remotely is a hard error", () => { - expect(() => - computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - connections: [ - { - id: 9, - slug: "hn-frontpage", - connector_key: "rss", - status: "active", - auth_profile_slug: null, - app_auth_profile_slug: null, - config: {}, - }, - ], - }), - ).toThrow(/bound to connector "rss" remotely.*declares "hackernews"/); - }); - - test("auth-profile slug bound to a different kind remotely is a hard error", () => { - expect(() => - computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - authProfiles: [ - { - slug: "hn-token", - connector_key: "hackernews", - profile_kind: "oauth_app", - status: "active", - }, - ], - }), - ).toThrow(/auth_profile "hn-token" is bound to hackernews\/oauth_app/); - }); - - test("credential rotation re-pushes: env profile shows update (credentials)", () => { - const plan = computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef], - authProfiles: [ - { - slug: "hn-token", - display_name: "HN token", - connector_key: "hackernews", - profile_kind: "env", - status: "active", - }, - ], - }); - const row = plan.rows.find( - (r) => r.kind === "auth-profile" && r.id === "hn-token", - ); - expect(row?.verb).toBe("update"); - expect(row && "changedFields" in row ? row.changedFields : []).toContain( - "credentials", - ); - }); - - test("a fully-converged remote state produces no connector create/update (except idempotent connector-def re-push)", () => { - // Build a remote snapshot that exactly mirrors connectorState(): the env - // auth profile has no declared-credential drift suppression, so it would - // re-push (update credentials). The acme connector def is installed, so it - // shows as a (no-op-on-server) "update". Everything else is noop. - const remote: RemoteSnapshot = { - ...emptyRemote(), - connectorDefinitions: [ - { key: "hackernews", installed: false, installable: true }, - { key: "x", installed: false, installable: true }, - { key: "acme", installed: true, installable: false }, - ], - authProfiles: [ - { - slug: "hn-token", - display_name: "HN token", - connector_key: "hackernews", - profile_kind: "env", - status: "active", - }, - { - slug: "x-account", - connector_key: "x", - profile_kind: "oauth_account", - status: "active", - }, - ], - connections: [ - { - id: 7, - slug: "hn-frontpage", - connector_key: "hackernews", - display_name: "HN front page", - status: "active", - auth_profile_slug: "hn-token", - app_auth_profile_slug: null, - config: {}, - }, - ], - feedsByConnectionId: new Map([ - [ - 7, - [ - { - id: 11, - connection_id: 7, - feed_key: "stories", - status: "active", - schedule: "0 * * * *", - config: {}, - }, - ], - ], - ]), - }; - const plan = computeDiff(connectorState(), remote); - // Only "update" rows allowed: the connector-def re-push and the - // env-credential re-push — both idempotent on the server. - const nonIdempotentChurn = plan.rows.filter( - (r) => - (r.verb === "create" || r.verb === "update") && - !(r.kind === "connector-definition") && - !(r.kind === "auth-profile" && r.id === "hn-token"), - ); - expect(nonIdempotentChurn).toEqual([]); - expect(plan.notes).toEqual([]); - }); - - test("connector-definition with an already-installed key renders as update, not create", () => { - const installedAcme = { key: "acme", installed: true, installable: false }; - const plan = computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [builtinConnectorDef, installedAcme], - }); - // connectorState()'s acme def has key:"acme"; it is installed remotely. - const row = plan.rows.find( - (r) => r.kind === "connector-definition" && r.id?.startsWith("acme"), - ); - expect(row?.verb).toBe("update"); - }); - - // ── round-4 ────────────────────────────────────────────────────────────── - - test("referenced-but-not-installed bundled connector becomes a connector-definition create row", () => { - const plan = computeDiff(connectorState(), { - ...emptyRemote(), - connectorDefinitions: [ - // hackernews: installable + has a server-side source_uri, not installed - { - key: "hackernews", - installed: false, - installable: true, - source_uri: "file:///app/connectors/hackernews.ts", - }, - // x: same - { - key: "x", - installed: false, - installable: true, - source_uri: "file:///app/connectors/x.ts", - }, - ], - }); - const hn = plan.rows.find( - (r) => r.kind === "connector-definition" && r.id === "hackernews", - ); - expect(hn?.verb).toBe("create"); - const x = plan.rows.find( - (r) => r.kind === "connector-definition" && r.id === "x", - ); - expect(x?.verb).toBe("create"); - // acme is locally declared (sourcePath) — it still gets its own row. - expect( - plan.rows.some( - (r) => r.kind === "connector-definition" && r.id?.startsWith("acme"), - ), - ).toBe(true); - }); - - test("a locally-supplied connector key is NOT also a bundled-install row (no double mutation)", () => { - // Pretend "acme" is *also* in the bundled catalog with a source_uri; the - // local .connector.ts should win — no bundled row for "acme". - const state = connectorState(); - // Make a connection reference "acme" so it's in referencedConnectorKeys. - state.connectors.connections.push({ - slug: "acme-conn", - connector: "acme", - feeds: [], - sourceFile: "connectors/acme.yaml", - }); - const plan = computeDiff(state, { - ...emptyRemote(), - connectorDefinitions: [ - { - key: "acme", - installed: false, - installable: true, - source_uri: "file:///app/connectors/acme.ts", - }, - ], - }); - const acmeRows = plan.rows.filter( - (r) => r.kind === "connector-definition" && r.id?.startsWith("acme"), - ); - // Exactly one row — the locally-declared def — never a bundled duplicate. - expect(acmeRows).toHaveLength(1); - }); + const builtinConnectorDef = { + key: "hackernews", + name: "Hacker News", + installed: false, + installable: true, + }; + + function connectorState() { + return buildState([], { + connectors: { + definitions: [ + { + key: "acme", + sourcePath: "/proj/connectors/acme.connector.ts", + sourceCode: "export default class {}", + sourceFile: "connectors/acme.connector.ts", + }, + ], + authProfiles: [ + { + slug: "hn-token", + connector: "hackernews", + kind: "env" as const, + name: "HN token", + credentials: { HN_TOKEN: "$HN_TOKEN" }, + sourceFile: "connectors/hackernews.yaml", + }, + { + slug: "x-account", + connector: "x", + kind: "oauth_account" as const, + sourceFile: "connectors/x.yaml", + }, + ], + connections: [ + { + slug: "hn-frontpage", + connector: "hackernews", + name: "HN front page", + authProfileSlug: "hn-token", + feeds: [{ feedKey: "stories", schedule: "0 * * * *" }], + sourceFile: "connectors/hackernews.yaml", + }, + ], + }, + }); + } + + test("create verbs for new connector def, auth profile, connection, feed", () => { + const plan = computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + }); + const def = plan.rows.find((r) => r.kind === "connector-definition"); + expect(def?.verb).toBe("create"); + const authEnv = plan.rows.find( + (r) => r.kind === "auth-profile" && r.id === "hn-token" + ); + expect(authEnv?.verb).toBe("create"); + const authOauth = plan.rows.find( + (r) => r.kind === "auth-profile" && r.id === "x-account" + ); + expect(authOauth?.verb).toBe("create"); + expect( + authOauth && "needsAuth" in authOauth ? authOauth.needsAuth : undefined + ).toBe(true); + const conn = plan.rows.find((r) => r.kind === "connection"); + expect(conn?.verb).toBe("create"); + const feed = plan.rows.find((r) => r.kind === "feed"); + expect(feed?.verb).toBe("create"); + expect(feed?.id).toBe("hn-frontpage/stories"); + }); + + test("noop when connection + feed already match remotely", () => { + const remote: RemoteSnapshot = { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + authProfiles: [ + { + slug: "hn-token", + display_name: "HN token", + connector_key: "hackernews", + profile_kind: "env", + status: "active", + }, + { + slug: "x-account", + connector_key: "x", + profile_kind: "oauth_account", + status: "active", + }, + ], + connections: [ + { + id: 7, + slug: "hn-frontpage", + connector_key: "hackernews", + display_name: "HN front page", + status: "active", + auth_profile_slug: "hn-token", + app_auth_profile_slug: null, + config: {}, + }, + ], + feedsByConnectionId: new Map([ + [ + 7, + [ + { + id: 11, + connection_id: 7, + feed_key: "stories", + status: "active", + schedule: "0 * * * *", + config: {}, + }, + ], + ], + ]), + }; + const plan = computeDiff(connectorState(), remote); + expect(plan.rows.find((r) => r.kind === "connection")?.verb).toBe("noop"); + expect(plan.rows.find((r) => r.kind === "feed")?.verb).toBe("noop"); + expect( + plan.rows.find((r) => r.kind === "auth-profile" && r.id === "x-account") + ?.verb + ).toBe("noop"); + }); + + test("update when feed schedule changes; needs-auth when oauth profile inactive", () => { + const remote: RemoteSnapshot = { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + authProfiles: [ + { + slug: "hn-token", + display_name: "HN token", + connector_key: "hackernews", + profile_kind: "env", + status: "active", + }, + { + slug: "x-account", + connector_key: "x", + profile_kind: "oauth_account", + status: "pending_auth", + }, + ], + connections: [ + { + id: 7, + slug: "hn-frontpage", + connector_key: "hackernews", + display_name: "HN front page", + status: "active", + auth_profile_slug: "hn-token", + app_auth_profile_slug: null, + config: {}, + }, + ], + feedsByConnectionId: new Map([ + [ + 7, + [ + { + id: 11, + connection_id: 7, + feed_key: "stories", + status: "active", + schedule: "0 0 * * *", + config: {}, + }, + ], + ], + ]), + }; + const plan = computeDiff(connectorState(), remote); + const feed = plan.rows.find((r) => r.kind === "feed"); + expect(feed?.verb).toBe("update"); + expect(feed && "changedFields" in feed ? feed.changedFields : []).toEqual([ + "schedule", + ]); + const authOauth = plan.rows.find( + (r) => r.kind === "auth-profile" && r.id === "x-account" + ); + expect( + authOauth && "needsAuth" in authOauth ? authOauth.needsAuth : undefined + ).toBe(true); + }); + + test("undeclared remote connector becomes an informational note (no uninstall)", () => { + const remote: RemoteSnapshot = { + ...emptyRemote(), + connectorDefinitions: [ + builtinConnectorDef, + { + key: "legacy", + name: "Legacy", + installed: true, + installable: false, + }, + ], + }; + const plan = computeDiff(connectorState(), remote); + expect(plan.notes.some((n) => n.includes('"legacy"'))).toBe(true); + expect( + plan.rows.some( + (r) => r.kind === "connector-definition" && r.id === "legacy" + ) + ).toBe(false); + }); + + test("connectors are skipped when --only is set", () => { + const plan = computeDiff(connectorState(), emptyRemote(), { + only: "agents", + }); + expect(plan.rows.some((r) => r.kind === "connection")).toBe(false); + expect(plan.rows.some((r) => r.kind === "connector-definition")).toBe( + false + ); + }); + + test("render includes the connectors sections", () => { + const plan = computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + }); + expect(renderPlan(plan)).toMatchSnapshot(); + }); + + // ── round-2 ────────────────────────────────────────────────────────────── + + test("connection slug bound to a different connector remotely is a hard error", () => { + expect(() => + computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + connections: [ + { + id: 9, + slug: "hn-frontpage", + connector_key: "rss", + status: "active", + auth_profile_slug: null, + app_auth_profile_slug: null, + config: {}, + }, + ], + }) + ).toThrow(/bound to connector "rss" remotely.*declares "hackernews"/); + }); + + test("auth-profile slug bound to a different kind remotely is a hard error", () => { + expect(() => + computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + authProfiles: [ + { + slug: "hn-token", + connector_key: "hackernews", + profile_kind: "oauth_app", + status: "active", + }, + ], + }) + ).toThrow(/auth_profile "hn-token" is bound to hackernews\/oauth_app/); + }); + + test("credential rotation re-pushes: env profile shows update (credentials)", () => { + const plan = computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef], + authProfiles: [ + { + slug: "hn-token", + display_name: "HN token", + connector_key: "hackernews", + profile_kind: "env", + status: "active", + }, + ], + }); + const row = plan.rows.find( + (r) => r.kind === "auth-profile" && r.id === "hn-token" + ); + expect(row?.verb).toBe("update"); + expect(row && "changedFields" in row ? row.changedFields : []).toContain( + "credentials" + ); + }); + + test("a fully-converged remote state produces no connector create/update (except idempotent connector-def re-push)", () => { + // Build a remote snapshot that exactly mirrors connectorState(): the env + // auth profile has no declared-credential drift suppression, so it would + // re-push (update credentials). The acme connector def is installed, so it + // shows as a (no-op-on-server) "update". Everything else is noop. + const remote: RemoteSnapshot = { + ...emptyRemote(), + connectorDefinitions: [ + { key: "hackernews", installed: false, installable: true }, + { key: "x", installed: false, installable: true }, + { key: "acme", installed: true, installable: false }, + ], + authProfiles: [ + { + slug: "hn-token", + display_name: "HN token", + connector_key: "hackernews", + profile_kind: "env", + status: "active", + }, + { + slug: "x-account", + connector_key: "x", + profile_kind: "oauth_account", + status: "active", + }, + ], + connections: [ + { + id: 7, + slug: "hn-frontpage", + connector_key: "hackernews", + display_name: "HN front page", + status: "active", + auth_profile_slug: "hn-token", + app_auth_profile_slug: null, + config: {}, + }, + ], + feedsByConnectionId: new Map([ + [ + 7, + [ + { + id: 11, + connection_id: 7, + feed_key: "stories", + status: "active", + schedule: "0 * * * *", + config: {}, + }, + ], + ], + ]), + }; + const plan = computeDiff(connectorState(), remote); + // Only "update" rows allowed: the connector-def re-push and the + // env-credential re-push — both idempotent on the server. + const nonIdempotentChurn = plan.rows.filter( + (r) => + (r.verb === "create" || r.verb === "update") && + !(r.kind === "connector-definition") && + !(r.kind === "auth-profile" && r.id === "hn-token") + ); + expect(nonIdempotentChurn).toEqual([]); + expect(plan.notes).toEqual([]); + }); + + test("connector-definition with an already-installed key renders as update, not create", () => { + const installedAcme = { key: "acme", installed: true, installable: false }; + const plan = computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [builtinConnectorDef, installedAcme], + }); + // connectorState()'s acme def has key:"acme"; it is installed remotely. + const row = plan.rows.find( + (r) => r.kind === "connector-definition" && r.id?.startsWith("acme") + ); + expect(row?.verb).toBe("update"); + }); + + // ── round-4 ────────────────────────────────────────────────────────────── + + test("referenced-but-not-installed bundled connector becomes a connector-definition create row", () => { + const plan = computeDiff(connectorState(), { + ...emptyRemote(), + connectorDefinitions: [ + // hackernews: installable + has a server-side source_uri, not installed + { + key: "hackernews", + installed: false, + installable: true, + source_uri: "file:///app/connectors/hackernews.ts", + }, + // x: same + { + key: "x", + installed: false, + installable: true, + source_uri: "file:///app/connectors/x.ts", + }, + ], + }); + const hn = plan.rows.find( + (r) => r.kind === "connector-definition" && r.id === "hackernews" + ); + expect(hn?.verb).toBe("create"); + const x = plan.rows.find( + (r) => r.kind === "connector-definition" && r.id === "x" + ); + expect(x?.verb).toBe("create"); + // acme is locally declared (sourcePath) — it still gets its own row. + expect( + plan.rows.some( + (r) => r.kind === "connector-definition" && r.id?.startsWith("acme") + ) + ).toBe(true); + }); + + test("a locally-supplied connector key is NOT also a bundled-install row (no double mutation)", () => { + // Pretend "acme" is *also* in the bundled catalog with a source_uri; the + // local .connector.ts should win — no bundled row for "acme". + const state = connectorState(); + // Make a connection reference "acme" so it's in referencedConnectorKeys. + state.connectors.connections.push({ + slug: "acme-conn", + connector: "acme", + feeds: [], + sourceFile: "connectors/acme.yaml", + }); + const plan = computeDiff(state, { + ...emptyRemote(), + connectorDefinitions: [ + { + key: "acme", + installed: false, + installable: true, + source_uri: "file:///app/connectors/acme.ts", + }, + ], + }); + const acmeRows = plan.rows.filter( + (r) => r.kind === "connector-definition" && r.id?.startsWith("acme") + ); + // Exactly one row — the locally-declared def — never a bundled duplicate. + expect(acmeRows).toHaveLength(1); + }); }); From a73d96488da45b4f13239d3ba06e0c5c3590a84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 00:44:35 +0100 Subject: [PATCH 3/6] fix(connectors): subdir-aware resolvers + userManaged on browser primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi review found four follow-ups to the browser/* subdir layout introduced in the previous commit: - packages/server/src/utils/connector-catalog.ts: server-side findBundledConnectorFile() was still flat-only — auto-install / device-reconcile / worker-poll would treat browser.evaluate as "no bundled source." Now mirrors the worker-side resolver (subdir first, underscore-flat fallback). Adds bundledConnectorSourcePath(filePath) so subdir paths round-trip through connectorSourcePathToUri. - packages/server/src/utils/ensure-connector-installed.ts + packages/server/src/worker-api/device-reconcile.ts: stop persisting basename(filePath) as source_path — collides on basename across subdirs and breaks source_uri resolution for subdir connectors. - packages/cli/src/commands/_lib/connector-loader.ts: CLI resolver was also flat-only. - packages/connectors/src/browser/{evaluate,fill_form,page_text}.ts: mark feeds userManaged so device-reconcile doesn't auto-wire them with config=NULL. These are bridge-composing primitives (script / url + fields / url are required, gateway-author-supplied), not end-user feeds. --- .../cli/src/commands/_lib/connector-loader.ts | 21 +++++++---- packages/connectors/src/browser/evaluate.ts | 5 +++ packages/connectors/src/browser/fill_form.ts | 3 ++ packages/connectors/src/browser/page_text.ts | 3 ++ .../server/src/utils/connector-catalog.ts | 35 +++++++++++++++---- .../src/utils/ensure-connector-installed.ts | 9 +++-- .../server/src/worker-api/device-reconcile.ts | 4 +-- 7 files changed, 63 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/_lib/connector-loader.ts b/packages/cli/src/commands/_lib/connector-loader.ts index 721464365..5421f87ee 100644 --- a/packages/cli/src/commands/_lib/connector-loader.ts +++ b/packages/cli/src/commands/_lib/connector-loader.ts @@ -44,13 +44,22 @@ const bundledFileCache = new Map(); export function findBundledConnectorFile(key: string): string | null { const cached = bundledFileCache.get(key); if (cached !== undefined) return cached; - const fileName = `${key.replace(/\./g, "_")}.ts`; + // Mirror the resolver in @lobu/connector-worker's compile-connector.ts: + // subdir layout (`browser.evaluate` → `browser/evaluate.ts`) first, then + // the flat underscore convention (`chrome.tabs` → `chrome_tabs.ts`). + const candidates = [ + `${key.replace(/\./g, "/")}.ts`, + `${key.replace(/\./g, "_")}.ts`, + ]; let found: string | null = null; - for (const candidate of SOURCE_DIR_CANDIDATES) { - const filePath = resolve(candidate, fileName); - if (existsSync(filePath)) { - found = filePath; - break; + outer: for (const dir of SOURCE_DIR_CANDIDATES) { + for (const fileName of candidates) { + const filePath = resolve(dir, fileName); + if (!filePath.startsWith(`${dir}/`)) continue; + if (existsSync(filePath)) { + found = filePath; + break outer; + } } } bundledFileCache.set(key, found); diff --git a/packages/connectors/src/browser/evaluate.ts b/packages/connectors/src/browser/evaluate.ts index 3cc3a75e7..ee16079ff 100644 --- a/packages/connectors/src/browser/evaluate.ts +++ b/packages/connectors/src/browser/evaluate.ts @@ -47,6 +47,11 @@ export default class BrowserEvaluateConnector extends ConnectorRuntime { 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'], diff --git a/packages/connectors/src/browser/fill_form.ts b/packages/connectors/src/browser/fill_form.ts index c6ade0c05..9854afc32 100644 --- a/packages/connectors/src/browser/fill_form.ts +++ b/packages/connectors/src/browser/fill_form.ts @@ -41,6 +41,9 @@ export default class BrowserFillFormConnector extends ConnectorRuntime { 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'], diff --git a/packages/connectors/src/browser/page_text.ts b/packages/connectors/src/browser/page_text.ts index 1acb95fdc..1f7c72a8e 100644 --- a/packages/connectors/src/browser/page_text.ts +++ b/packages/connectors/src/browser/page_text.ts @@ -41,6 +41,9 @@ export default class BrowserPageTextConnector extends ConnectorRuntime { 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'], diff --git a/packages/server/src/utils/connector-catalog.ts b/packages/server/src/utils/connector-catalog.ts index 89fd7cc30..13c7d7768 100644 --- a/packages/server/src/utils/connector-catalog.ts +++ b/packages/server/src/utils/connector-catalog.ts @@ -134,19 +134,42 @@ const bundledFileCache = new Map(); export function findBundledConnectorFile(key: string): string | null { const cached = bundledFileCache.get(key); if (cached !== undefined) return cached; - const fileName = `${key.replace(/\./g, '_')}.ts`; + // Mirror the worker-side resolver in compile-connector.ts: try the + // subdir layout first (`browser.evaluate` → `browser/evaluate.ts`) and + // fall back to the flat underscore convention (`chrome.tabs` → + // `chrome_tabs.ts`). Keep these in sync if either side changes. + const candidates = [`${key.replace(/\./g, '/')}.ts`, `${key.replace(/\./g, '_')}.ts`]; let found: string | null = null; - for (const candidate of DEFAULT_CONNECTOR_DIR_CANDIDATES) { - const filePath = resolve(candidate, fileName); - if (existsSync(filePath)) { - found = filePath; - break; + outer: for (const dir of DEFAULT_CONNECTOR_DIR_CANDIDATES) { + for (const fileName of candidates) { + const filePath = resolve(dir, fileName); + if (!filePath.startsWith(`${dir}/`)) continue; + if (existsSync(filePath)) { + found = filePath; + break outer; + } } } bundledFileCache.set(key, found); return found; } +// Derive the persisted source_path (relative to the bundled-connectors +// catalog dir) for a file resolved by findBundledConnectorFile. Used by +// auto-install / device-reconcile so subdir-grouped connectors +// (`browser/evaluate.ts`) round-trip correctly through +// `connectorSourcePathToUri`. Falls back to basename if the file lives +// outside every known candidate (shouldn't happen in practice, but keeps +// the call site simple). +export function bundledConnectorSourcePath(filePath: string): string { + for (const dir of DEFAULT_CONNECTOR_DIR_CANDIDATES) { + if (filePath.startsWith(`${dir}/`)) { + return relative(dir, filePath); + } + } + return relative(resolve(filePath, '..'), filePath); +} + export function normalizeFileSourceUri(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; diff --git a/packages/server/src/utils/ensure-connector-installed.ts b/packages/server/src/utils/ensure-connector-installed.ts index 1433eb913..27c005908 100644 --- a/packages/server/src/utils/ensure-connector-installed.ts +++ b/packages/server/src/utils/ensure-connector-installed.ts @@ -9,9 +9,12 @@ * from source_path, so edits to .ts files take effect without reinstalling. */ -import { basename } from 'node:path'; import { getDb } from '../db/client'; -import { compileConnectorFromFile, findBundledConnectorFile } from './connector-catalog'; +import { + bundledConnectorSourcePath, + compileConnectorFromFile, + findBundledConnectorFile, +} from './connector-catalog'; import { extractConnectorMetadata } from './connector-compiler'; import { upsertConnectorDefinitionRecords } from './connector-definition-install'; import logger from './logger'; @@ -65,7 +68,7 @@ export async function ensureConnectorInstalled(params: { throw new Error('Connector must have key, name, and version.'); } - const sourcePath = basename(filePath); + const sourcePath = bundledConnectorSourcePath(filePath); await upsertConnectorDefinitionRecords({ sql, organizationId: params.organizationId, diff --git a/packages/server/src/worker-api/device-reconcile.ts b/packages/server/src/worker-api/device-reconcile.ts index cce919b11..d990e3e52 100644 --- a/packages/server/src/worker-api/device-reconcile.ts +++ b/packages/server/src/worker-api/device-reconcile.ts @@ -9,10 +9,10 @@ * creating runs nothing can claim. */ -import { basename } from 'node:path'; import { getDb, pgTextArray } from '../db/client'; import { type BundledDeviceConnector, + bundledConnectorSourcePath, compileConnectorFromFile, findBundledConnectorFile, getBundledDeviceConnectors, @@ -158,7 +158,7 @@ async function ensureDeviceConnectorWired( compiledCode: null, compiledCodeHash: null, sourceCode: null, - sourcePath: basename(filePath), + sourcePath: bundledConnectorSourcePath(filePath), }, }); From 4293fb923b6bacc43553c17453334d669c2ca6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 01:02:52 +0100 Subject: [PATCH 4/6] fix(mcp,device-reconcile,owletto): inherited main failures + pi follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/server/src/workspace/multi-tenant.ts: when a Bearer header is present but PAT verify, OAuth verify, AND session-cookie lookup all fail, return RFC 6750 `invalid_token` 401 (with WWW-Authenticate error=invalid_token) instead of falling through to anonymous and returning generic `unauthorized` later. Fixes mcp/auth.test.ts "should reject expired/invalid OAuth access token" — they assert the standards-compliant error code so MCP clients (Claude Desktop etc.) surface "bad token" rather than mistaking it for "no auth needed." - packages/server/src/worker-api/device-reconcile.ts: short-circuit ensureDeviceConnectorWired() when declaredFeedKeys is empty (every feed userManaged → nothing to auto-wire). Avoids a compile + upsert + no-auth-connection adopt per Chrome poll for browser.* primitives. Definition + version row still get installed lazily by ensureConnectorInstalled when a composing connector runs them. (Second-round pi review finding.) - packages/owletto: bump pointer to 2552ed0 — fix(build): vite target=esnext for top-level await in main.tsx (lobu-ai/owletto#161, merged). Unblocks PR Validation / build-test which had been failing on packages/owletto's vite build since the auth-pivot landed. --- packages/owletto | 2 +- .../server/src/worker-api/device-reconcile.ts | 9 +++++++++ packages/server/src/workspace/multi-tenant.ts | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/owletto b/packages/owletto index aeb3324cf..2552ed0c6 160000 --- a/packages/owletto +++ b/packages/owletto @@ -1 +1 @@ -Subproject commit aeb3324cfa60c8948ecf3b62d8c70229019464c2 +Subproject commit 2552ed0c6d11f8c6b24e257fe989069b614650fb diff --git a/packages/server/src/worker-api/device-reconcile.ts b/packages/server/src/worker-api/device-reconcile.ts index d990e3e52..9fa72e37f 100644 --- a/packages/server/src/worker-api/device-reconcile.ts +++ b/packages/server/src/worker-api/device-reconcile.ts @@ -55,6 +55,15 @@ async function ensureDeviceConnectorWired( ): Promise { const sql = getDb(); + // Primitive connectors (every feed marked `userManaged`, e.g. browser/*) + // have nothing to auto-wire — feeds are minted by composing bridge + // connectors via /api/workers/me/feeds, not by device-reconcile. Skip + // before doing any DB or compile work so each Chrome poll doesn't pay + // the cost. The connector_definition + version row will be installed + // lazily by ensureConnectorInstalled when a composing connector + // actually runs one of these primitives. + if (declaredFeedKeys.length === 0) return; + // Self-heal the device pin against the user's current fleet. Cheap, idempotent // (the WHERE matches nothing when the pin is already a valid fresh device), and // runs even on the fast path so a stale pin doesn't silently strand the feeds. diff --git a/packages/server/src/workspace/multi-tenant.ts b/packages/server/src/workspace/multi-tenant.ts index cd1fe2c72..b7760d77f 100644 --- a/packages/server/src/workspace/multi-tenant.ts +++ b/packages/server/src/workspace/multi-tenant.ts @@ -574,6 +574,22 @@ export class MultiTenantProvider implements WorkspaceProvider { // Session validation failed, continue to anonymous } + // If the client sent `Authorization: Bearer …` and we got here, it + // wasn't a valid PAT, OAuth access token, or Better Auth session token — + // all three resolution paths above bailed. Return the RFC 6750 + // `invalid_token` error (not the generic anonymous fall-through), so + // standards-compliant clients surface "bad token" rather than mistaking + // it for "no auth needed." + if (authHeader?.startsWith('Bearer ')) { + return c.json( + { error: 'invalid_token', error_description: 'Invalid or expired access token' }, + 401, + { + 'WWW-Authenticate': `Bearer realm="${baseUrl}/.well-known/oauth-protected-resource", error="invalid_token"`, + } + ); + } + // 3) Anonymous: allow through with null org for discovery (tools/list, initialize) // tools/call will enforce org context at the handler level. if (!requestedOrgId) { From bfbf80b691ba0ec016ff8de11a327500735c61cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 01:08:59 +0100 Subject: [PATCH 5/6] fix(server): repair sibling-walk SPA template/dist paths post-rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packages/web → packages/owletto rename in #817 updated APP_ROOT-relative candidates but missed two: - The `../web/{dist,index.html}` sibling-deploy candidate was kept verbatim ("for out-of-monorepo deployments"), but after the rename the sibling dir is `../owletto/`, not `../web/`. The stale path silently misses on every lookup. - The `path.resolve(process.cwd(), '../packages/owletto/...')` candidate has always resolved to `packages/packages/owletto/...` (double `packages`) when cwd is `packages/server` — i.e. exactly the layout the integration job runs under. Rewriting as `../owletto/...` lands in the right sibling. Symptoms: every `public-pages-contract.test.ts` (and `mcp/auth.test.ts` indirectly) failure on `main` since 7a724562 — `buildPublicPageModel` returned a real model but `loadAnySpaHtmlTemplate()` returned null, so the catch-all fell through to the JSON discovery response with `Cache-Control: no-store`. Reproduces against PGlite locally; fixed and verified all 3 public-pages tests pass. Same broken pattern repaired in `utils/public-origin.ts:hasLocalFrontend` (8 candidates, 4 corrected). --- packages/server/src/index.ts | 16 ++++++++++++---- packages/server/src/utils/public-origin.ts | 12 ++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 60e2e88bb..5d9d2815c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -151,9 +151,9 @@ async function resolveWebDistDirectory(): Promise { const candidates = [ process.env.WEB_DIST_DIR?.trim(), path.resolve(APP_ROOT, 'packages/owletto/dist'), - path.resolve(APP_ROOT, '../web/dist'), + path.resolve(APP_ROOT, '../owletto/dist'), path.resolve(process.cwd(), 'packages/owletto/dist'), - path.resolve(process.cwd(), '../packages/owletto/dist'), + path.resolve(process.cwd(), '../owletto/dist'), ].filter((candidate): candidate is string => Boolean(candidate)); for (const candidate of candidates) { @@ -187,11 +187,19 @@ async function loadSpaHtmlTemplate(): Promise { } async function loadFallbackSpaHtmlTemplate(): Promise { + // APP_ROOT is the server package dir (packages/server). The sibling + // candidate must walk one level up first to land in `packages/`, then + // into `owletto/`. The previous `../packages/owletto/...` form here and + // in resolveWebDistDirectory was a copy-paste from when this file was + // working from a different anchor — it resolves to + // `packages/packages/owletto/...` and silently misses every time. + // Same story for `../web/...` which was left over from the + // packages/web → packages/owletto rename (#817). const candidates = [ path.resolve(APP_ROOT, 'packages/owletto/index.html'), - path.resolve(APP_ROOT, '../web/index.html'), + path.resolve(APP_ROOT, '../owletto/index.html'), path.resolve(process.cwd(), 'packages/owletto/index.html'), - path.resolve(process.cwd(), '../packages/owletto/index.html'), + path.resolve(process.cwd(), '../owletto/index.html'), ]; for (const candidate of candidates) { diff --git a/packages/server/src/utils/public-origin.ts b/packages/server/src/utils/public-origin.ts index 0e5f12e66..a7e343ede 100644 --- a/packages/server/src/utils/public-origin.ts +++ b/packages/server/src/utils/public-origin.ts @@ -57,16 +57,20 @@ export function hasLocalFrontend(): boolean { const envDist = process.env.WEB_DIST_DIR?.trim(); const isDevelopment = process.env.NODE_ENV === 'development'; + // Sibling-walk candidates must go through `../owletto/`, not + // `../packages/owletto/` (resolves to `packages/packages/owletto/`). + // `../web/...` is a leftover from the packages/web → packages/owletto + // rename in #817. const candidates = [ envDist ? path.join(envDist, 'index.html') : undefined, path.resolve(APP_ROOT, 'packages/owletto/dist/index.html'), - path.resolve(APP_ROOT, '../web/dist/index.html'), + path.resolve(APP_ROOT, '../owletto/dist/index.html'), path.resolve(process.cwd(), 'packages/owletto/dist/index.html'), - path.resolve(process.cwd(), '../packages/owletto/dist/index.html'), + path.resolve(process.cwd(), '../owletto/dist/index.html'), isDevelopment ? path.resolve(APP_ROOT, 'packages/owletto/index.html') : undefined, - isDevelopment ? path.resolve(APP_ROOT, '../web/index.html') : undefined, + isDevelopment ? path.resolve(APP_ROOT, '../owletto/index.html') : undefined, isDevelopment ? path.resolve(process.cwd(), 'packages/owletto/index.html') : undefined, - isDevelopment ? path.resolve(process.cwd(), '../packages/owletto/index.html') : undefined, + isDevelopment ? path.resolve(process.cwd(), '../owletto/index.html') : undefined, ].filter((candidate): candidate is string => Boolean(candidate)); for (const candidate of candidates) { From 9363776ff94f5252b2990ad81514d012a188fb62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 18 May 2026 01:20:28 +0100 Subject: [PATCH 6/6] fix(device-reconcile,catalog): pi review pass-3 follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third pi review flagged two issues with the prior round: - worker-api/device-reconcile.ts: the earlier early-return-when-empty short-circuited too aggressively. `local.directory` and other device connectors whose only feed is `userManaged` rely on ensureDeviceConnectorWired to install the connector_definition + version + connection — `/api/workers/me/feeds` 404s on "no connection wired" otherwise. Move the short-circuit into the fast-path check instead: when the connection + version already exist AND there's nothing to verify (declaredFeedKeys empty), fast-path returns without compiling. First poll still does the full install; subsequent polls are zero work. - utils/connector-catalog.ts: the one-level subdir scan was descending into `connectors/src/__tests__/` and trying to extract catalog metadata from test files that import `bun:test`, producing esbuild warnings on every cold scan. Skip `__tests__` and any leading-underscore dir. --- packages/server/src/utils/connector-catalog.ts | 5 +++++ .../server/src/worker-api/device-reconcile.ts | 18 +++++++----------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/server/src/utils/connector-catalog.ts b/packages/server/src/utils/connector-catalog.ts index 13c7d7768..88463d0fb 100644 --- a/packages/server/src/utils/connector-catalog.ts +++ b/packages/server/src/utils/connector-catalog.ts @@ -385,6 +385,11 @@ export async function listCatalogConnectorDefinitions( continue; } if (entry.isDirectory()) { + // Skip private / non-connector folders. `__tests__` ships test files + // that import `bun:test`, which esbuild can't resolve and which + // surface as catalog-cold-scan warnings; any leading-underscore name + // is by convention not a connector grouping. + if (entry.name === '__tests__' || entry.name.startsWith('_')) continue; try { const subEntries = await readdir(entryPath, { withFileTypes: true }); for (const sub of subEntries.sort((a, b) => a.name.localeCompare(b.name))) { diff --git a/packages/server/src/worker-api/device-reconcile.ts b/packages/server/src/worker-api/device-reconcile.ts index 9fa72e37f..dd8fb60fc 100644 --- a/packages/server/src/worker-api/device-reconcile.ts +++ b/packages/server/src/worker-api/device-reconcile.ts @@ -55,15 +55,6 @@ async function ensureDeviceConnectorWired( ): Promise { const sql = getDb(); - // Primitive connectors (every feed marked `userManaged`, e.g. browser/*) - // have nothing to auto-wire — feeds are minted by composing bridge - // connectors via /api/workers/me/feeds, not by device-reconcile. Skip - // before doing any DB or compile work so each Chrome poll doesn't pay - // the cost. The connector_definition + version row will be installed - // lazily by ensureConnectorInstalled when a composing connector - // actually runs one of these primitives. - if (declaredFeedKeys.length === 0) return; - // Self-heal the device pin against the user's current fleet. Cheap, idempotent // (the WHERE matches nothing when the pin is already a valid fresh device), and // runs even on the fast path so a stale pin doesn't silently strand the feeds. @@ -124,8 +115,13 @@ async function ensureDeviceConnectorWired( if ( existingReady[0]?.connection_id && existingReady[0]?.version_key && - declaredFeedKeys.length > 0 && - declaredFeedKeys.every((feedKey) => activeFeedKeys.has(feedKey)) + // userManaged-only connectors (e.g. local.directory, browser/*) report + // declaredFeedKeys=[]. Once the connection + definition are installed, + // every subsequent poll has nothing to verify — fast-path out. + // Composing primitives still hit /api/workers/me/feeds to mint + // explicit per-instance rows; that path is unchanged. + (declaredFeedKeys.length === 0 || + declaredFeedKeys.every((feedKey) => activeFeedKeys.has(feedKey))) ) { return; }