@lobu/cli 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  2. package/dist/commands/_lib/apply/apply-cmd.js +160 -12
  3. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  4. package/dist/commands/_lib/apply/client.d.ts +106 -0
  5. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  6. package/dist/commands/_lib/apply/client.js +163 -2
  7. package/dist/commands/_lib/apply/client.js.map +1 -1
  8. package/dist/commands/_lib/apply/desired-state.d.ts +53 -0
  9. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  10. package/dist/commands/_lib/apply/desired-state.js +182 -5
  11. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  12. package/dist/commands/_lib/apply/diff.d.ts +12 -1
  13. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  14. package/dist/commands/_lib/apply/diff.js +106 -7
  15. package/dist/commands/_lib/apply/diff.js.map +1 -1
  16. package/dist/commands/_lib/connector-loader.d.ts +3 -0
  17. package/dist/commands/_lib/connector-loader.d.ts.map +1 -0
  18. package/dist/commands/_lib/connector-loader.js +129 -0
  19. package/dist/commands/_lib/connector-loader.js.map +1 -0
  20. package/dist/commands/_lib/connector-run-cmd.d.ts +35 -0
  21. package/dist/commands/_lib/connector-run-cmd.d.ts.map +1 -0
  22. package/dist/commands/_lib/connector-run-cmd.js +351 -0
  23. package/dist/commands/_lib/connector-run-cmd.js.map +1 -0
  24. package/dist/commands/_lib/export/export-cmd.d.ts +35 -0
  25. package/dist/commands/_lib/export/export-cmd.d.ts.map +1 -0
  26. package/dist/commands/_lib/export/export-cmd.js +329 -0
  27. package/dist/commands/_lib/export/export-cmd.js.map +1 -0
  28. package/dist/commands/agent.d.ts.map +1 -1
  29. package/dist/commands/agent.js +11 -14
  30. package/dist/commands/agent.js.map +1 -1
  31. package/dist/commands/chat.d.ts.map +1 -1
  32. package/dist/commands/chat.js +19 -5
  33. package/dist/commands/chat.js.map +1 -1
  34. package/dist/commands/connector.d.ts +3 -0
  35. package/dist/commands/connector.d.ts.map +1 -0
  36. package/dist/commands/connector.js +5 -0
  37. package/dist/commands/connector.js.map +1 -0
  38. package/dist/commands/context.d.ts +7 -0
  39. package/dist/commands/context.d.ts.map +1 -1
  40. package/dist/commands/context.js +19 -2
  41. package/dist/commands/context.js.map +1 -1
  42. package/dist/commands/dev.d.ts +15 -0
  43. package/dist/commands/dev.d.ts.map +1 -1
  44. package/dist/commands/dev.js +156 -4
  45. package/dist/commands/dev.js.map +1 -1
  46. package/dist/commands/doctor.d.ts.map +1 -1
  47. package/dist/commands/doctor.js +2 -3
  48. package/dist/commands/doctor.js.map +1 -1
  49. package/dist/commands/eval.d.ts.map +1 -1
  50. package/dist/commands/eval.js +12 -13
  51. package/dist/commands/eval.js.map +1 -1
  52. package/dist/commands/init.d.ts.map +1 -1
  53. package/dist/commands/init.js +5 -1
  54. package/dist/commands/init.js.map +1 -1
  55. package/dist/commands/login.d.ts.map +1 -1
  56. package/dist/commands/login.js +22 -16
  57. package/dist/commands/login.js.map +1 -1
  58. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  59. package/dist/commands/memory/_lib/browser-auth-cmd.js +15 -144
  60. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  61. package/dist/commands/token.d.ts.map +1 -1
  62. package/dist/commands/token.js +1 -4
  63. package/dist/commands/token.js.map +1 -1
  64. package/dist/commands/validate.d.ts.map +1 -1
  65. package/dist/commands/validate.js +4 -13
  66. package/dist/commands/validate.js.map +1 -1
  67. package/dist/config/loader.js +2 -2
  68. package/dist/config/loader.js.map +1 -1
  69. package/dist/connectors/README.md +0 -1
  70. package/dist/connectors/apple_photos.ts +178 -0
  71. package/dist/connectors/browser-scraper-utils.ts +76 -0
  72. package/dist/connectors/chrome.ts +351 -0
  73. package/dist/connectors/chrome_bookmarks.ts +79 -0
  74. package/dist/connectors/chrome_downloads.ts +80 -0
  75. package/dist/connectors/chrome_history.ts +80 -0
  76. package/dist/connectors/github.ts +1 -0
  77. package/dist/connectors/google_calendar.ts +14 -2
  78. package/dist/connectors/google_play.ts +22 -2
  79. package/dist/connectors/hackernews.ts +37 -2
  80. package/dist/connectors/index.ts +15 -1
  81. package/dist/connectors/reddit.ts +1 -0
  82. package/dist/connectors/revolut.ts +10 -13
  83. package/dist/connectors/rss.ts +33 -8
  84. package/dist/connectors/trustpilot.ts +31 -20
  85. package/dist/connectors/website.ts +7 -68
  86. package/dist/connectors/whatsapp.ts +12 -21
  87. package/dist/db/migrations/20260514130000_connection_action_modes.sql +103 -0
  88. package/dist/db/migrations/20260514160000_auth_profiles_mirror_mode.sql +32 -0
  89. package/dist/db/migrations/20260515120000_agents_per_org_pk.sql +66 -0
  90. package/dist/db/migrations/20260515150000_geo_enrichment.sql +208 -0
  91. package/dist/db/migrations/20260515160000_drop_agents_org_id_unique.sql +24 -0
  92. package/dist/db/migrations/20260515170000_auth_profiles_default_for_connector.sql +23 -0
  93. package/dist/db/migrations/20260516120000_agents_per_org_pk_swap.sql +125 -0
  94. package/dist/db/migrations/20260516200000_events_search_tsv.sql +134 -0
  95. package/dist/db/migrations/20260516200100_events_lifecycle_changes_index.sql +25 -0
  96. package/dist/db/migrations/20260517010000_drop_unused_indexes.sql +49 -0
  97. package/dist/db/migrations/20260517020000_softdelete_orphan_feeds.sql +56 -0
  98. package/dist/db/migrations/20260517030000_pat_worker_id_binding.sql +27 -0
  99. package/dist/db/migrations/20260517040000_archive_orphan_watchers.sql +30 -0
  100. package/dist/db/migrations/20260517050000_watcher_agent_id_not_null.sql +34 -0
  101. package/dist/db/migrations/20260517060000_watcher_schema_additions.sql +78 -0
  102. package/dist/db/migrations/20260517150000_goals_primitive.sql +55 -0
  103. package/dist/db/migrations/20260517160000_drop_goals_primitive.sql +45 -0
  104. package/dist/db/migrations/20260518000000_pending_interactions.sql +49 -0
  105. package/dist/db/migrations/20260518010000_runs_heartbeat_reaper_index.sql +22 -0
  106. package/dist/db/migrations/20260518020000_runs_heartbeat_inflight_narrow.sql +36 -0
  107. package/dist/db/migrations/20260518040000_agent_transcript_snapshot.sql +54 -0
  108. package/dist/db/migrations/20260518050000_runs_denormalize_agent_conversation.sql +36 -0
  109. package/dist/db/migrations/20260518060000_revert_runs_denormalize.sql +29 -0
  110. package/dist/db/migrations/20260518070000_runs_heartbeat_inflight_widen.sql +33 -0
  111. package/dist/eval/client.d.ts.map +1 -1
  112. package/dist/eval/client.js +11 -0
  113. package/dist/eval/client.js.map +1 -1
  114. package/dist/eval/grader.js +2 -1
  115. package/dist/eval/grader.js.map +1 -1
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +84 -1
  118. package/dist/index.js.map +1 -1
  119. package/dist/internal/context.d.ts +13 -1
  120. package/dist/internal/context.d.ts.map +1 -1
  121. package/dist/internal/context.js +83 -8
  122. package/dist/internal/context.js.map +1 -1
  123. package/dist/internal/credentials.d.ts +5 -0
  124. package/dist/internal/credentials.d.ts.map +1 -1
  125. package/dist/internal/credentials.js +75 -1
  126. package/dist/internal/credentials.js.map +1 -1
  127. package/dist/internal/index.d.ts +2 -2
  128. package/dist/internal/index.d.ts.map +1 -1
  129. package/dist/internal/index.js +2 -2
  130. package/dist/internal/index.js.map +1 -1
  131. package/dist/internal/local-env.d.ts.map +1 -1
  132. package/dist/internal/local-env.js +9 -2
  133. package/dist/internal/local-env.js.map +1 -1
  134. package/dist/server.bundle.mjs +7085 -2832
  135. package/dist/start-local.bundle.mjs +8269 -3656
  136. package/package.json +7 -5
  137. package/dist/connectors/google_photos.ts +0 -776
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Chrome Bookmarks Connector — Owletto for Chrome only.
3
+ *
4
+ * Opt-in ambient feed. The Chrome extension advertises capability
5
+ * `browser.bookmarks` when the user grants the `bookmarks` Chrome
6
+ * permission via the sidepanel Permissions panel; the gateway then
7
+ * auto-wires this connector.
8
+ *
9
+ * Emits one event per bookmark (with its folder path). Backfills the
10
+ * full tree on first sync, then streams `bookmarks.onCreated/onRemoved/
11
+ * onChanged/onMoved` thereafter.
12
+ *
13
+ * Cloud-side `sync()` / `execute()` throw — actual work happens in
14
+ * apps/chrome/feeds-bookmarks.js in the extension.
15
+ */
16
+
17
+ import {
18
+ type ActionResult,
19
+ type ConnectorDefinition,
20
+ ConnectorRuntime,
21
+ type SyncContext,
22
+ type SyncResult,
23
+ } from '@lobu/connector-sdk';
24
+
25
+ const BRIDGE_ONLY =
26
+ 'chrome.bookmarks runs only on a worker advertising capability "browser.bookmarks" (Owletto for Chrome with bookmarks permission granted).';
27
+
28
+ export default class ChromeBookmarksConnector extends ConnectorRuntime {
29
+ readonly definition: ConnectorDefinition = {
30
+ key: 'chrome.bookmarks',
31
+ name: 'Chrome bookmarks',
32
+ description:
33
+ "Bookmarks (and folder structure) from the paired Chrome profile. Opt-in — requires the user to grant the extension's optional `bookmarks` permission.",
34
+ version: '0.1.0',
35
+ faviconDomain: 'google.com',
36
+ requiredCapability: 'browser.bookmarks',
37
+ runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] },
38
+ authSchema: { methods: [{ type: 'none' }] },
39
+ feeds: {
40
+ bookmarks: {
41
+ key: 'bookmarks',
42
+ name: 'Bookmarks',
43
+ description:
44
+ 'One event per bookmark. Backfills the full tree on first sync via chrome.bookmarks.getTree, then streams onCreated / onRemoved / onChanged / onMoved.',
45
+ configSchema: { type: 'object', properties: {} },
46
+ eventKinds: {
47
+ bookmark: {
48
+ description: 'One row per bookmark add/remove/edit.',
49
+ metadataSchema: {
50
+ type: 'object',
51
+ required: ['source', 'origin_id', 'event_type'],
52
+ properties: {
53
+ source: { type: 'string', const: 'chrome_bookmarks' },
54
+ origin_id: { type: 'string' },
55
+ event_type: {
56
+ enum: ['created', 'removed', 'changed', 'moved'],
57
+ },
58
+ bookmark_id: { type: 'string' },
59
+ title: { type: 'string' },
60
+ url: { type: 'string' },
61
+ parent_folder_id: { type: 'string' },
62
+ parent_folder_path: { type: 'string' },
63
+ date_added: { type: 'string', format: 'date-time' },
64
+ },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ },
70
+ };
71
+
72
+ async sync(_ctx: SyncContext): Promise<SyncResult> {
73
+ throw new Error(BRIDGE_ONLY);
74
+ }
75
+
76
+ async execute(): Promise<ActionResult> {
77
+ throw new Error(BRIDGE_ONLY);
78
+ }
79
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Chrome Downloads Connector — Owletto for Chrome only.
3
+ *
4
+ * Opt-in ambient feed. The Chrome extension advertises capability
5
+ * `browser.downloads` when the user grants the `downloads` Chrome
6
+ * permission via the sidepanel Permissions panel; the gateway then
7
+ * auto-wires this connector.
8
+ *
9
+ * Emits one event per download (filename, source URL, mime type, size,
10
+ * finish time). Backfills recent downloads on first sync via
11
+ * chrome.downloads.search({}), then streams chrome.downloads.onCreated /
12
+ * onChanged.
13
+ *
14
+ * Cloud-side `sync()` / `execute()` throw — actual work happens in
15
+ * apps/chrome/feeds-downloads.js in the extension.
16
+ */
17
+
18
+ import {
19
+ type ActionResult,
20
+ type ConnectorDefinition,
21
+ ConnectorRuntime,
22
+ type SyncContext,
23
+ type SyncResult,
24
+ } from '@lobu/connector-sdk';
25
+
26
+ const BRIDGE_ONLY =
27
+ 'chrome.downloads runs only on a worker advertising capability "browser.downloads" (Owletto for Chrome with downloads permission granted).';
28
+
29
+ export default class ChromeDownloadsConnector extends ConnectorRuntime {
30
+ readonly definition: ConnectorDefinition = {
31
+ key: 'chrome.downloads',
32
+ name: 'Chrome downloads',
33
+ description:
34
+ "Files downloaded in the paired Chrome profile, with their source URLs. Opt-in — requires the user to grant the extension's optional `downloads` permission.",
35
+ version: '0.1.0',
36
+ faviconDomain: 'google.com',
37
+ requiredCapability: 'browser.downloads',
38
+ runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] },
39
+ authSchema: { methods: [{ type: 'none' }] },
40
+ feeds: {
41
+ downloads: {
42
+ key: 'downloads',
43
+ name: 'Downloads',
44
+ description:
45
+ 'One event per download. Backfills via chrome.downloads.search({}), then streams onCreated / onChanged (state=complete).',
46
+ configSchema: { type: 'object', properties: {} },
47
+ eventKinds: {
48
+ download: {
49
+ description: 'One row per file the user downloaded.',
50
+ metadataSchema: {
51
+ type: 'object',
52
+ required: ['source', 'origin_id'],
53
+ properties: {
54
+ source: { type: 'string', const: 'chrome_downloads' },
55
+ origin_id: { type: 'string' },
56
+ download_id: { type: 'integer' },
57
+ filename: { type: 'string' },
58
+ source_url: { type: 'string', format: 'uri' },
59
+ referrer: { type: 'string' },
60
+ mime: { type: 'string' },
61
+ bytes: { type: 'integer' },
62
+ started_at: { type: 'string', format: 'date-time' },
63
+ finished_at: { type: 'string', format: 'date-time' },
64
+ state: { type: 'string' },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ };
72
+
73
+ async sync(_ctx: SyncContext): Promise<SyncResult> {
74
+ throw new Error(BRIDGE_ONLY);
75
+ }
76
+
77
+ async execute(): Promise<ActionResult> {
78
+ throw new Error(BRIDGE_ONLY);
79
+ }
80
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Chrome History Connector — Owletto for Chrome only.
3
+ *
4
+ * Opt-in ambient feed. The Chrome extension advertises capability
5
+ * `browser.history` when the user grants the `history` Chrome permission
6
+ * via the sidepanel Permissions panel; the gateway then auto-wires this
7
+ * connector to the paired chrome-extension device.
8
+ *
9
+ * Emits one event per page load (URL, title, visit time, transition type).
10
+ * The backfill feed seeds with up to 90 days of history on first sync; the
11
+ * live feed streams `history.onVisited` thereafter.
12
+ *
13
+ * Cloud-side `sync()` / `execute()` throw — actual work happens in
14
+ * apps/chrome/feeds-history.js in the extension.
15
+ */
16
+
17
+ import {
18
+ type ActionResult,
19
+ type ConnectorDefinition,
20
+ ConnectorRuntime,
21
+ type SyncContext,
22
+ type SyncResult,
23
+ } from '@lobu/connector-sdk';
24
+
25
+ const BRIDGE_ONLY =
26
+ 'chrome.history runs only on a worker advertising capability "browser.history" (Owletto for Chrome with history permission granted).';
27
+
28
+ export default class ChromeHistoryConnector extends ConnectorRuntime {
29
+ readonly definition: ConnectorDefinition = {
30
+ key: 'chrome.history',
31
+ name: 'Chrome history',
32
+ description:
33
+ "Every page the user visits in their paired Chrome profile, with visit time + transition type. Opt-in — requires the user to grant the extension's optional `history` permission.",
34
+ version: '0.1.0',
35
+ faviconDomain: 'google.com',
36
+ requiredCapability: 'browser.history',
37
+ runtime: { platforms: ['chrome-extension'] as unknown as ['macos'] },
38
+ authSchema: { methods: [{ type: 'none' }] },
39
+ feeds: {
40
+ visits: {
41
+ key: 'visits',
42
+ name: 'Visits',
43
+ description:
44
+ 'One event per page visit. Backfills ~90 days on first sync (chrome.history.search), then streams new visits via the chrome.history.onVisited listener.',
45
+ configSchema: { type: 'object', properties: {} },
46
+ eventKinds: {
47
+ page_visit: {
48
+ description: 'One row per visit observed.',
49
+ metadataSchema: {
50
+ type: 'object',
51
+ required: ['source', 'origin_id', 'url', 'visit_time'],
52
+ properties: {
53
+ source: { type: 'string', const: 'chrome_history' },
54
+ origin_id: { type: 'string' },
55
+ url: { type: 'string', format: 'uri' },
56
+ title: { type: 'string' },
57
+ visit_time: { type: 'string', format: 'date-time' },
58
+ transition_type: {
59
+ description:
60
+ 'How the user got to the page: link, typed, auto_bookmark, auto_subframe, manual_subframe, generated, start_page, form_submit, reload, keyword, keyword_generated.',
61
+ type: 'string',
62
+ },
63
+ visit_id: { type: 'integer' },
64
+ visit_count: { type: 'integer' },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ };
72
+
73
+ async sync(_ctx: SyncContext): Promise<SyncResult> {
74
+ throw new Error(BRIDGE_ONLY);
75
+ }
76
+
77
+ async execute(): Promise<ActionResult> {
78
+ throw new Error(BRIDGE_ONLY);
79
+ }
80
+ }
@@ -208,6 +208,7 @@ export default class GitHubConnector extends ConnectorRuntime {
208
208
  name: 'GitHub',
209
209
  description: 'Collects GitHub issues/discussions and executes repo actions.',
210
210
  version: '1.2.0',
211
+ faviconDomain: 'github.com',
211
212
  authSchema: {
212
213
  methods: [
213
214
  {
@@ -255,7 +255,14 @@ export default class GoogleCalendarConnector extends ConnectorRuntime {
255
255
  let pageToken: string | undefined;
256
256
  let nextSyncToken: string | undefined;
257
257
 
258
- while (true) {
258
+ // Safety bound — at 250 events/page, 200 pages = 50k events, more than
259
+ // any reasonable calendar window. Stops a runaway loop if the upstream
260
+ // ever returns a self-referential page token.
261
+ const MAX_PAGES = 200;
262
+ let pages = 0;
263
+
264
+ while (pages < MAX_PAGES) {
265
+ pages++;
259
266
  // Always request a full page — `maxResults` is a soft cap on *stored*
260
267
  // events, not a reason to shrink the request size (shrinking to 1 once the
261
268
  // cap is hit would crawl a busy calendar one event per round-trip).
@@ -350,7 +357,12 @@ export default class GoogleCalendarConnector extends ConnectorRuntime {
350
357
  let pageToken: string | undefined;
351
358
  let nextSyncToken: string | undefined;
352
359
 
353
- while (true) {
360
+ // Same hard ceiling as the full-sync path — defensive only.
361
+ const MAX_PAGES = 200;
362
+ let pages = 0;
363
+
364
+ while (pages < MAX_PAGES) {
365
+ pages++;
354
366
  const params = new URLSearchParams({
355
367
  maxResults: String(Math.max(1, Math.min(250, maxResults - events.length))),
356
368
  syncToken,
@@ -136,6 +136,12 @@ async function fetchReviewsPage(
136
136
 
137
137
  if (!res.ok) {
138
138
  if (res.status === 404) throw new Error('App not found (404)');
139
+ if (res.status === 429) {
140
+ const retryAfter = res.headers.get('Retry-After');
141
+ throw new Error(
142
+ `Google Play rate limit (429). Retry after ${retryAfter ?? 'unknown'} seconds.`
143
+ );
144
+ }
139
145
  throw new Error(`Google Play request failed: ${res.status} ${res.statusText}`);
140
146
  }
141
147
 
@@ -143,14 +149,28 @@ async function fetchReviewsPage(
143
149
 
144
150
  // Response starts with ")]}'" (security prefix), then a newline, then JSON.
145
151
  // The library skips the first 5 characters.
146
- const outer = JSON.parse(text.substring(5));
152
+ // Wrap parse in try/catch — Google sometimes returns an HTML interstitial
153
+ // (captcha / geo-block / maintenance) with status 200, which would bubble up
154
+ // as an unhelpful SyntaxError otherwise.
155
+ let outer: any;
156
+ try {
157
+ outer = JSON.parse(text.substring(5));
158
+ } catch {
159
+ const preview = text.substring(0, 120).replace(/\s+/g, ' ');
160
+ throw new Error(`Google Play returned non-JSON response: ${preview}`);
161
+ }
147
162
  const innerJson: string | null = outer?.[0]?.[2];
148
163
 
149
164
  if (innerJson === null || innerJson === undefined) {
150
165
  return { reviews: [], nextToken: null };
151
166
  }
152
167
 
153
- const data = JSON.parse(innerJson);
168
+ let data: any;
169
+ try {
170
+ data = JSON.parse(innerJson);
171
+ } catch {
172
+ throw new Error('Google Play returned malformed inner JSON payload');
173
+ }
154
174
  return {
155
175
  reviews: extractReviews(data, appId),
156
176
  nextToken: extractPaginationToken(data),
@@ -16,6 +16,7 @@ import {
16
16
  type SyncContext,
17
17
  type SyncResult,
18
18
  } from '@lobu/connector-sdk';
19
+ import { validatePublicUrl } from './browser-scraper-utils.ts';
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Algolia HN API types
@@ -261,11 +262,36 @@ export default class HackerNewsConnector extends ConnectorRuntime {
261
262
  `&numericFilters=${encodeURIComponent(`created_at_i>${lookbackTimestamp}`)}`;
262
263
 
263
264
  const response = await fetch(url);
265
+
266
+ // Honor Algolia's rate-limit response so we don't hammer them and turn
267
+ // a transient 429 into "Unexpected token < in JSON" when the next call
268
+ // returns an HTML error page.
269
+ if (response.status === 429) {
270
+ const retryAfter = response.headers.get('Retry-After');
271
+ const waitMs = retryAfter ? Math.min(60_000, Math.max(1, Number(retryAfter)) * 1000) : 5000;
272
+ await this.sleep(Number.isFinite(waitMs) ? waitMs : 5000);
273
+ continue;
274
+ }
275
+
264
276
  if (!response.ok) {
265
- throw new Error(`Algolia API error (${response.status}): ${await response.text()}`);
277
+ const text = await response.text().catch(() => '');
278
+ throw new Error(`Algolia API error (${response.status}): ${text}`);
279
+ }
280
+
281
+ // Algolia normally returns JSON, but proxies/captive portals occasionally
282
+ // return HTML. Surface a useful error instead of a bare SyntaxError that
283
+ // makes the connector look broken when the upstream is at fault.
284
+ let data: AlgoliaResponse;
285
+ try {
286
+ data = (await response.json()) as AlgoliaResponse;
287
+ } catch (err) {
288
+ const message = err instanceof Error ? err.message : String(err);
289
+ throw new Error(`Algolia API returned non-JSON response: ${message}`);
266
290
  }
267
291
 
268
- const data = (await response.json()) as AlgoliaResponse;
292
+ if (!data || !Array.isArray(data.hits)) {
293
+ throw new Error('Algolia API returned an unexpected response shape');
294
+ }
269
295
 
270
296
  for (const hit of data.hits) {
271
297
  if (contentType === 'comment') {
@@ -404,6 +430,15 @@ export default class HackerNewsConnector extends ConnectorRuntime {
404
430
 
405
431
  private async fetchExternalContent(url: string): Promise<string | null> {
406
432
  try {
433
+ // SSRF guard — `url` is supplied by whoever submitted the HN story and
434
+ // is therefore attacker-controllable. Refuse to fetch private/internal
435
+ // addresses (loopback, 169.254.169.254 cloud metadata, RFC1918, etc.).
436
+ try {
437
+ validatePublicUrl(url);
438
+ } catch {
439
+ return null;
440
+ }
441
+
407
442
  const controller = new AbortController();
408
443
  const timeoutId = setTimeout(() => controller.abort(), this.CONTENT_FETCH_TIMEOUT);
409
444
 
@@ -1,15 +1,29 @@
1
1
  export * from './apple_health.ts';
2
+ export * from './apple_photos.ts';
2
3
  export * from './apple_screen_time.ts';
3
4
  export * from './local_directory.ts';
4
5
  export * from './browser-scraper-utils.ts';
5
6
  export * from './capterra.ts';
7
+ // Chrome — paired Chrome profile via the Owletto for Chrome extension.
8
+ // One connector exposing feeds.open_tabs (auto-wired tab snapshot) +
9
+ // actions.{navigate, get_accessibility_tree, click_ref, type_ref,
10
+ // wait_for_selector, screenshot, evaluate} (one-shot tools the extension
11
+ // dispatcher executes via chrome.debugger + a custom DOM accessibility
12
+ // snapshot). Replaces the four prior standalone connectors
13
+ // (chrome.tabs / browser.evaluate / browser.page_text / browser.fill_form).
14
+ // chrome.history / chrome.bookmarks / chrome.downloads are opt-in
15
+ // ambient feeds that auto-wire when the user grants the corresponding
16
+ // optional permission in the extension's Permissions panel.
17
+ export * from './chrome.ts';
18
+ export * from './chrome_history.ts';
19
+ export * from './chrome_bookmarks.ts';
20
+ export * from './chrome_downloads.ts';
6
21
  export * from './g2.ts';
7
22
  export * from './github.ts';
8
23
  export * from './glassdoor.ts';
9
24
  export * from './gmaps.ts';
10
25
  export * from './google_calendar.ts';
11
26
  export * from './google_gmail.ts';
12
- export * from './google_photos.ts';
13
27
  export * from './google_play.ts';
14
28
  export * from './hackernews.ts';
15
29
  export * from './ios_appstore.ts';
@@ -79,6 +79,7 @@ export default class RedditConnector extends ConnectorRuntime {
79
79
  name: 'Reddit',
80
80
  description: 'Fetches posts and comments from Reddit subreddits or search queries.',
81
81
  version: '1.0.0',
82
+ faviconDomain: 'reddit.com',
82
83
  authSchema: {
83
84
  methods: [
84
85
  {
@@ -142,12 +142,9 @@ function extractAmountAndCurrency(
142
142
  const amt = record.amount;
143
143
  if (amt && typeof amt === "object") {
144
144
  const obj = amt as Record<string, unknown>;
145
- const value =
146
- typeof obj.value === "number"
147
- ? obj.value
148
- : typeof obj.amount === "number"
149
- ? obj.amount
150
- : null;
145
+ let value: number | null = null;
146
+ if (typeof obj.value === "number") value = obj.value;
147
+ else if (typeof obj.amount === "number") value = obj.amount;
151
148
  const currency = typeof obj.currency === "string" ? obj.currency : null;
152
149
  if (value !== null && currency) return { amount: value, currency };
153
150
  }
@@ -200,13 +197,13 @@ function extractBalance(
200
197
  record: Record<string, unknown>,
201
198
  currency: string,
202
199
  ): number | undefined {
203
- const raw =
204
- typeof record.balance === "number"
205
- ? record.balance
206
- : record.balance && typeof record.balance === "object"
207
- ? ((record.balance as Record<string, unknown>).value ??
208
- (record.balance as Record<string, unknown>).amount)
209
- : undefined;
200
+ let raw: unknown;
201
+ if (typeof record.balance === "number") {
202
+ raw = record.balance;
203
+ } else if (record.balance && typeof record.balance === "object") {
204
+ const obj = record.balance as Record<string, unknown>;
205
+ raw = obj.value ?? obj.amount;
206
+ }
210
207
  if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
211
208
  return Number.isInteger(raw) ? minorUnitsToMajor(raw, currency) : raw;
212
209
  }
@@ -15,6 +15,7 @@ import {
15
15
  type SyncContext,
16
16
  type SyncResult,
17
17
  } from '@lobu/connector-sdk';
18
+ import { validatePublicUrl } from './browser-scraper-utils.ts';
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Types
@@ -211,6 +212,11 @@ export default class RSSConnector extends ConnectorRuntime {
211
212
  // -------------------------------------------------------------------------
212
213
 
213
214
  private async fetchAndParseFeed(feedUrl: string, maxItems: number): Promise<RSSFeedItem[]> {
215
+ // SSRF guard at the trust boundary. `feed_urls` is operator/user supplied
216
+ // via connector config and must not be allowed to target loopback, RFC1918,
217
+ // or cloud-metadata IPs from the gateway process.
218
+ validatePublicUrl(feedUrl);
219
+
214
220
  const controller = new AbortController();
215
221
  const timeoutId = setTimeout(() => controller.abort(), this.FETCH_TIMEOUT_MS);
216
222
 
@@ -222,18 +228,13 @@ export default class RSSConnector extends ConnectorRuntime {
222
228
  Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
223
229
  },
224
230
  });
225
-
226
- clearTimeout(timeoutId);
227
-
228
231
  if (!response.ok) {
229
232
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
230
233
  }
231
-
232
234
  const xml = await response.text();
233
235
  return this.parseXml(xml, feedUrl, maxItems);
234
- } catch (err) {
236
+ } finally {
235
237
  clearTimeout(timeoutId);
236
- throw err;
237
238
  }
238
239
  }
239
240
 
@@ -413,8 +414,32 @@ export default class RSSConnector extends ConnectorRuntime {
413
414
  case '#39':
414
415
  return "'";
415
416
  default:
416
- if (hex) return String.fromCharCode(parseInt(hex, 16));
417
- if (decimal) return String.fromCharCode(parseInt(decimal, 10));
417
+ // Use fromCodePoint, not fromCharCode — astral-plane characters
418
+ // (emoji, CJK extension B+, etc.) have code points > 0xFFFF which
419
+ // fromCharCode silently truncates, producing mojibake in feed
420
+ // titles. Guard the range so a malformed entity doesn't throw.
421
+ if (hex) {
422
+ const cp = parseInt(hex, 16);
423
+ if (Number.isFinite(cp) && cp >= 0 && cp <= 0x10ffff) {
424
+ try {
425
+ return String.fromCodePoint(cp);
426
+ } catch {
427
+ return _match;
428
+ }
429
+ }
430
+ return _match;
431
+ }
432
+ if (decimal) {
433
+ const cp = parseInt(decimal, 10);
434
+ if (Number.isFinite(cp) && cp >= 0 && cp <= 0x10ffff) {
435
+ try {
436
+ return String.fromCodePoint(cp);
437
+ } catch {
438
+ return _match;
439
+ }
440
+ }
441
+ return _match;
442
+ }
418
443
  return _match;
419
444
  }
420
445
  }
@@ -98,7 +98,11 @@ export default class TrustpilotConnector extends ConnectorRuntime {
98
98
  throw new Error('Either business_url or business_name is required');
99
99
  }
100
100
 
101
- const baseUrl = businessUrl || `https://www.trustpilot.com/review/${businessName}`;
101
+ // encodeURIComponent the user-supplied businessName so a value like
102
+ // "../search?foo=bar" can't escape the /review/ path on trustpilot.com.
103
+ const baseUrl =
104
+ businessUrl ||
105
+ `https://www.trustpilot.com/review/${encodeURIComponent(businessName ?? '')}`;
102
106
  validateUrlDomain(baseUrl, 'trustpilot.com');
103
107
 
104
108
  const userDataDir = getBrowserUserDataDir(ctx.sessionState);
@@ -161,27 +165,34 @@ export default class TrustpilotConnector extends ConnectorRuntime {
161
165
  // Filter reviews with meaningful content (more than 10 chars)
162
166
  const reviews: TrustpilotReview[] = rawReviews.filter((r) => r.text && r.text.length > 10);
163
167
 
164
- // Transform to EventEnvelope format
165
- const events: EventEnvelope[] = reviews.map((review) => {
168
+ // Transform to EventEnvelope format. Drop rows whose `date` attribute
169
+ // was missing/invalid in the DOM — `new Date("")` yields an Invalid
170
+ // Date, which downstream sorting/checkpointing then can't compare, and
171
+ // an empty `date` made `origin_id` collide on `-<author>` across rows.
172
+ const events: EventEnvelope[] = reviews.flatMap((review) => {
166
173
  const content = review.title ? `${review.title}\n\n${review.text}` : review.text;
167
-
168
- return {
169
- origin_id: `${review.date}-${review.author}`,
170
- payload_text: content,
171
- author_name: review.author,
172
- occurred_at: new Date(review.date),
173
- origin_type: 'review',
174
- score: calculateEngagementScore('trustpilot', {
175
- rating: review.rating,
176
- helpful_count: 0,
177
- }),
178
- source_url: baseUrl,
179
- metadata: {
180
- rating: review.rating,
181
- helpful_count: 0,
182
- title: review.title,
174
+ const parsedDate = review.date ? new Date(review.date) : null;
175
+ if (!parsedDate || Number.isNaN(parsedDate.getTime())) return [];
176
+
177
+ return [
178
+ {
179
+ origin_id: `${review.date}-${review.author}`,
180
+ payload_text: content,
181
+ author_name: review.author,
182
+ occurred_at: parsedDate,
183
+ origin_type: 'review',
184
+ score: calculateEngagementScore('trustpilot', {
185
+ rating: review.rating,
186
+ helpful_count: 0,
187
+ }),
188
+ source_url: baseUrl,
189
+ metadata: {
190
+ rating: review.rating,
191
+ helpful_count: 0,
192
+ title: review.title,
193
+ },
183
194
  },
184
- };
195
+ ];
185
196
  });
186
197
 
187
198
  return {