feat(chrome-extension): MV3 connector with auto-pair via Mac native messaging#773
Conversation
…ility authorization - @lobu/core capabilities.ts: platform-keyed allowlist registry + authorizeCapabilities(). Device workers can only advertise capabilities whitelisted for their reported platform; anything outside the allowlist is silently dropped before connector matching. - worker-api.ts: intersect advertised capabilities with the registry for user-scoped workers; trusted-fleet (no platform) path unchanged. - apps/chrome/: MV3 service-worker + sidepanel-iframe scaffold. Pairs via standard RFC 8628 OAuth device-authorization flow against the existing gateway endpoints (same as the Mac app — no new endpoint needed). Baseline capabilities: browser.tabs, browser.scripting, browser.debugger. Optional history/bookmarks via runtime chrome.permissions.request(). - bridge.js: typed postMessage broker between sidepanel iframe and service worker. Named-ops allowlist, origin-checked, deny-by-default — defends the privileged extension surface from the (trusted today) embedded owletto-web app. Verified end-to-end against local PGlite: device_workers row appears with platform=chrome-extension and the authorized capability set after pairing.
…connector Three follow-ups to the initial Chrome-extension scaffold: apps/chrome (extension): - permissions.html / permissions.js: opt-in / revoke UI for the optional Chrome permissions (history, bookmarks). Toggling updates the granted set; the next /api/workers/poll re-advertises capabilities automatically (background.js recomputes via chrome.permissions.contains each cycle). - sidepanel topbar adds a "Permissions" link to the new page. - background.js gains a minimal run executor (executeRun): on each poll, if the gateway hands back a chrome.tabs run, it queries the live tab list, streams one batch of `tab_snapshot` events, and marks the run complete. Non-chrome.tabs runs fail fast with a marker — heartbeat loop, multi-batch streaming, and action runs are explicit v2 backlog items in SCOPE.md. packages/web (owletto-web): - /embedded route: chromeless layout (no sidebar, no nav, no command palette) targeted by the extension's sidepanel iframe. Reads worker_id from the URL fragment. Reuses the existing isStandalonePage path in __root.tsx. packages/server (gateway): - CSP frame-ancestors picks up `chrome-extension:` for /embedded only — every other HTML response keeps the existing 'self' + lobu.ai allowlist so the rest of the app can't be embedded by arbitrary extensions. packages/connectors: - chrome_tabs.ts: requiredCapability='browser.tabs', runtime.platforms=['chrome-extension']. Lists open tabs as `tab_snapshot` events. Cloud sync/execute throw (bridge-only) — actual work lives in the extension's service worker. Typecheck clean. End-to-end smoke: device pairs, advertises authorized capability set, auto-wires the chrome.tabs connector via existing device- reconcile loop, executor handles the run shape end-to-end (single batch).
…panel Submodule pointer follows the matching commit on feat/chrome-extension-embedded-route in lobu-ai/owletto-web. Branch will need to be pushed before the parent feat/chrome-extension-connector branch goes up — current SHA is local-only.
Lobu/Owletto is self-hosted — the extension can't ship with a fixed
origin. First-run pairing now asks for the user's gateway URL before the
OAuth dance:
- config.js: drop GATEWAY_URL constant. Add getGatewayUrl() /
getEmbeddedAppUrl() helpers that read from chrome.storage. The hardcoded
http://localhost:8787 default is now just the pairing-screen pre-fill.
- pairing.html / pairing.js: new setup step before the OAuth flow. URL is
validated by fetching the well-known OAuth discovery doc; the extension
requests chrome.permissions.request({origins}) for the entered host so
subsequent fetches don't hit CORS.
- background.js, sidepanel.js, bridge.js: read gateway URL from storage at
call time. Falls back to opening pairing.html when missing.
- permissions.html: shows the configured server with a "Change" action
that clears creds + URL and re-runs setup.
- manifest.json: drop static host_permissions entries for the (placeholder)
owletto.ai hosts. Origin grants now happen at runtime via
optional_host_permissions: ["<all_urls>"] — also cleaner for Web Store
review.
Typecheck clean. The previous local-dev pairing path still works: leave
the default http://localhost:8787, click Continue, then Get pairing code.
When the Owletto Mac app is installed and signed in, Owletto for Chrome
auto-pairs on first run with zero second login — no URL typing, no OAuth
device-code dance.
Gateway:
- worker-api.ts: mintDeviceChildToken — POST /api/me/devices/mint-child-token.
Auth: caller's bearer. Body: {platform, label?}. Generates a new
worker_id, mints a fresh PAT in the caller's personal org with the
device_worker:run scope, returns {worker_id, access_token, gateway_url}.
Platform is gated against @lobu/core/capabilities (chrome-extension only
for now).
Extension:
- manifest.json: re-add `nativeMessaging` permission.
- pairing.js: on load, attempt chrome.runtime.connectNative("ai.owletto.bridge")
with a 2.5s timeout. On success: request host-permission for the
returned gateway origin, persist {gatewayUrl, workerId, accessToken},
close. On timeout/error: fall back to existing URL-setup → OAuth flow.
Mac bridge:
- ChromeBridgeHost.swift (NEW): two responsibilities. runHostIfRequested()
serves a single native-messaging request when launched with
--owletto-bridge (4-byte length-prefixed JSON on stdin/stdout); reads the
user's stored OAuth bearer from KeychainTokenStore, calls
/api/me/devices/mint-child-token, writes the response. installManifests()
drops ai.owletto.bridge.json into Chrome / Brave / Arc / Edge
NativeMessagingHosts directories, idempotent, with allowed_origins from
LOBU_OWLETTO_CHROME_EXTENSION_ID env override.
- LobuApp.swift init(): calls runHostIfRequested() before SwiftUI scene
builds, then installManifests().
Outstanding (next slice):
- ChromeBridgeHost.swift needs to be dragged into the Xcode project's
Lobu target manually — until then LobuApp.swift's init() won't compile.
Avoiding hand-editing project.pbxproj from this session.
- Web Store extension ID will be hardcoded once published; today only the
env override drives the manifest's allowed_origins.
Typecheck clean.
- Lobu.xcodeproj/project.pbxproj: add ChromeBridgeHost.swift to the PBXFileReference, PBXBuildFile, PBXGroup, and PBXSourcesBuildPhase sections. IDs follow the existing sequential B*0015 slot. Xcode now picks up the file in the Lobu target without a manual drag-and-drop. - ChromeBridgeHost.swift: switch Result<…, String> to Result<…, BridgeError> (Swift requires the error type to conform to Error), and fix the keychain store class name (KeychainCredentialStore, not KeychainTokenStore). Verified: `xcodebuild -project apps/mac/Lobu.xcodeproj -scheme Lobu -configuration Debug build` → BUILD SUCCEEDED.
…ck, DEBUG creds env Three end-to-end blockers found while exercising the Mac bridge via a synthetic Chrome native-messaging spawn: 1. ChromeBridgeHost.readFrame consumed bytes via FileHandle.availableData then used .prefix(4) to "peek" the length header. availableData is read-once — any payload bytes that arrived in the same chunk as the header were dropped, and the subsequent payload-read loop blocked on EOF. Rewrote to accumulate into a single buffer and slice out header + payload from it. 2. mintChildToken called URLSession.shared.dataTask().resume() + sem.wait() from the main thread. URLSession.shared's default delegate queue is the main queue, so the completion handler couldn't fire — classic deadlock. Switched to a dedicated ephemeral URLSession with its own OperationQueue. 3. Production builds read OAuth creds from keychain (correct). For debug/ad-hoc-signed dev builds TCC blocks unsigned binaries from reading keychain items silently — the bridge subprocess hangs forever waiting for a prompt that can't render. Added a DEBUG-only env-var fallback (LOBU_BRIDGE_TEST_BASE_URL / LOBU_BRIDGE_TEST_TOKEN) so the bridge flow can be exercised without code signing. #if DEBUG-gated; never read in RELEASE. Plus granular [bridge] stderr logging to make future plumbing issues easy to diagnose. Verified end-to-end against the local gateway: 4-byte length-prefixed JSON frame in, mint-child-token call returns 200, response frame out.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a Chrome MV3 extension (pairing, sidepanel, permissions, runtime bridge, service worker polling and tab-snapshot runs), server-side capability allowlisting and child-token minting, a chrome-tabs connector declaration, and a macOS native-messaging bridge for SSO pairing. ChangesChrome Extension and Device Worker Support
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 102e6c66eb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const capabilityMatchSet = isUserScopedWorker | ||
| ? advertisedCapabilities | ||
| : [''].concat(advertisedCapabilities); | ||
| ? authorizedCapabilities |
There was a problem hiding this comment.
Authorize capabilities in the run-claim SQL
This only swaps the trusted-worker match set to authorizedCapabilities; the user-scoped branches in the pollWorkerJob claim query still compare cd.required_capability against advertisedCapabilities. In the scenario this guard is meant to prevent (for example a chrome-extension token advertising os.shell), the device still matches and claims any unpinned or pinned run with that required capability, so the new allowlist is bypassed for actual run dispatch even though the registry/logging dropped it.
Useful? React with 👍 / 👎.
| const PLATFORM_ALLOWLIST: Record<string, readonly string[]> = { | ||
| macos: [...OS_CAPABILITIES, ...BROWSER_CAPABILITIES], |
There was a problem hiding this comment.
Preserve existing macOS device capabilities
With this allowlist, a macOS device is only allowed to retain os.* and browser.* capabilities, but the existing Mac app advertises screentime, local_directory, healthkit, photos, and whatsapp_local, and the corresponding bundled connectors require those exact strings. On the next user-scoped Mac poll those capabilities are written back as an empty/different set and reconcileDeviceCapabilities() will stop seeing the user's Mac as eligible for the existing Apple Photos/Screen Time/local folder/WhatsApp connectors, pausing or preventing those device syncs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/server/src/worker-api.ts (1)
154-186:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winThe new allowlist never reaches the actual claim checks.
This block filters and logs capabilities, but user-scoped scheduling still uses raw
advertisedCapabilitiesbelow, and the legacybrowsergate still comes straight from the request body. A device can keep claiming disallowed lanes by sending forbidden caps orbrowser: true; only the stored metadata gets filtered.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/server/src/worker-api.ts` around lines 154 - 186, The current capability filtering (normalizeAdvertisedCapabilities -> authorizedCapabilities via authorizeCapabilities) is never applied to actual claim checks: ensure downstream checks use authorizedCapabilities (not advertisedCapabilities) and that the legacy browser gate uses the stored/normalized hasBrowser derived from the worker metadata (e.g., the hasBrowser variable computed from capabilities) rather than the raw request body; update any code that references advertisedCapabilities or reads browser from the request to instead reference authorizedCapabilities and hasBrowser so user-scoped workers cannot claim disallowed lanes or spoof browser=true.
🧹 Nitpick comments (1)
apps/chrome/pairing.html (1)
72-95: ⚡ Quick winAdd an explicit label for the gateway URL input.
A visible or screen-reader label for
#gateway-urlwill improve keyboard/screen-reader usability with near-zero implementation cost.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/chrome/pairing.html` around lines 72 - 95, The input `#gateway-url` lacks a visible/screen-reader label; add one by inserting a <label for="gateway-url">Gateway URL</label> immediately before the input (or, if you prefer no visible text, add a semantic screen-reader-only label element or set aria-label="Gateway URL" on the input) so assistive tech and keyboard users can identify the field; ensure the label text matches the purpose (e.g., "Owletto server URL" or "Gateway URL") and keep the input's id "gateway-url" as the for target.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/chrome/background.js`:
- Around line 91-95: Replace the setInterval-based loop in startPolling with
chrome.alarms: stop using pollTimer/setInterval, create an alarm (e.g. name
"poll") via chrome.alarms.create using periodInMinutes = POLL_INTERVAL_MS /
60000, and register chrome.alarms.onAlarm listener to call pollOnce; still call
pollOnce() immediately for the first run. Also update any cleanup in stopPolling
to chrome.alarms.clear("poll") instead of clearInterval/clearing pollTimer, and
remove pollTimer usage throughout the module.
In `@apps/chrome/bridge.js`:
- Around line 104-107: The requestOptionalPermission branch in bridge.js
currently forwards any string into chrome.permissions.request; restrict this by
implementing a whitelist of allowed optional permissions (e.g.,
ALLOWED_OPTIONAL_PERMISSIONS constant) and validate the perm (obtained via
stringOrThrow) against that list inside the "requestOptionalPermission" case
before calling chrome.permissions.request; if the permission is not in the
whitelist, throw or return a clear error/denied response so only explicitly
allowed optional permissions can be requested from the iframe.
In `@apps/chrome/pairing.js`:
- Around line 208-210: The current check allows any http: URL; change the
validation so only https: is accepted except when the URL is an http: loopback
for local development. Replace the /^https?:$/.test(url.protocol) gate with a
strict check: accept when url.protocol === 'https:' OR when url.protocol ===
'http:' AND url.hostname is a loopback host (e.g., 'localhost', '127.0.0.1',
IPv4 127.* via regex, or IPv6 '::1'/'[::1]'). Update the error message
accordingly via the same setupStatus.textContent branch and return when
validation fails; locate and modify the conditional around url.protocol and
setupStatus.textContent in pairing.js.
- Around line 170-173: The chrome.permissions.request call is being invoked
during initialization causing it to fail; move that call into a direct
user-gesture handler (e.g., create a click handler like handleRequestPermissions
or attach to an existing button's onclick) and call chrome.permissions.request({
origins: [`${cleanBase}/*`] }) only from that handler; after moving, keep the
existing granted check and also inspect chrome.runtime.lastError to handle and
log failures from the permission request.
- Around line 295-347: The polling loop using setInterval (pollTimer) causes
overlapping requests and ignores runtime changes to intervalMs (e.g., when
response.error === "slow_down"); replace it with a recursive setTimeout-based
loop (a function like pollOnce or doPoll) that awaits fetchJson, checks
Date.now() > deadline, updates intervalMs when response.error === "slow_down",
and schedules the next poll via setTimeout(nextPoll, intervalMs) only after the
current request completes; ensure you still clear any outstanding timer when
pairing succeeds or expires and keep using existing symbols (pollTimer,
intervalMs, deadline, discovery.token_endpoint, fetchJson, pollStatus,
STORAGE_KEYS) so storage writes and UI updates remain unchanged.
In `@apps/chrome/permissions.js`:
- Around line 24-35: In the changeServerBtn.addEventListener("click", ...)
handler, capture the existing gateway URL from STORAGE_KEY_GATEWAY_URL before
you clear storage, derive its origin (scheme + host + optional port) and call
chrome.permissions.remove({ origins: [origin + "/*" or the exact origin pattern
used when requesting permission] }) to revoke that host permission, then proceed
to await chrome.storage.local.remove([...]) as currently implemented; reference
STORAGE_KEY_GATEWAY_URL and the changeServerBtn click handler to locate where to
read the stored URL, parse it to an origin, and call chrome.permissions.remove
to revoke the prior server origin.
In `@apps/chrome/README.md`:
- Around line 53-54: Replace the stale sentence "Native-messaging SSO with the
Mac bridge (skip the second login when Mac is installed) is a v2 backlog item —
see `SCOPE.md`." in the README and update it to state that a first-cut native
bridge pairing path is now included in the extension (remove "v2 backlog"
wording), and point readers to the current doc or section (e.g., SCOPE.md or the
new pairing docs) for setup details so onboarding guidance is accurate.
In `@apps/chrome/SCOPE.md`:
- Around line 60-62: Update the outstanding note in SCOPE.md that states
"ChromeBridgeHost.swift needs to be added to the Xcode project's Lobu target" —
either remove it or change it to reflect current status: if the file has been
added, delete the sentence or mark it as resolved; if it still needs action,
change "won't compile" to a clear TODO with the responsible PR/issue and
reference LobuApp.swift's init() and ChromeBridgeHost.swift so contributors
aren't misled. Ensure the note accurately references the Lobu target wiring and
current merge status.
In `@apps/chrome/sidepanel.js`:
- Around line 34-49: The gate logic in sidepanel.js only checks
STORAGE_KEYS.accessToken but always injects worker=${...} into the iframe URL;
update the readiness check to also require STORAGE_KEYS.workerId (from the
stored object returned by chrome.storage.local.get and the getEmbeddedAppUrl
result) and if either is missing set gate.hidden = false and return so frame.src
(the URL building that uses stored[STORAGE_KEYS.workerId]) is never invoked with
an undefined worker id.
- Around line 60-67: The message listener currently only checks ev.origin
(embeddedOrigin) which allows any same-origin window to send messages; modify
the handler in window.addEventListener to also verify the sender matches the
specific iframe by checking ev.source === frame.contentWindow before processing.
Update the conditional that gates processing (the branch that references
embeddedOrigin and ev.origin) to include ev.source equality to
frame.contentWindow so pending.set(...), port.postMessage(...), and id
generation (nextId) only occur for messages from that exact iframe instance.
In `@apps/mac/Lobu/ChromeBridgeHost.swift`:
- Around line 46-59: runHostIfRequested currently accepts any argv beginning
with "chrome-extension://" which allows local processes to spoof origins; update
runHostIfRequested to parse the extension ID from CommandLine.arguments[1] and
validate it against the same allowlist used by installManifests() (e.g., pull or
reference the canonical allowedOrigins set or validation helper used when
creating manifests); if the origin is not in the allowlist, return without
calling NativeMessagingLoop.run and avoid emitting tokens. Apply the same
validation logic to the other host-entry path noted in the review (the alternate
entry that runs at the 69-77 region) so both entry points reject non-allowed
extension IDs before starting the native messaging loop.
In `@packages/connectors/src/index.ts`:
- Line 7: The barrel currently uses "export * from './chrome_tabs.ts'" which
doesn't re-export the default export ChromeTabsConnector, so consumers can't
import it from the package root; update the package entry barrel to explicitly
re-export the default as a named export (e.g., add an export line that exports
ChromeTabsConnector from chrome_tabs.ts) or change chrome_tabs.ts to export
ChromeTabsConnector as a named export and then keep the existing export *
line—target the index.ts barrel and the ChromeTabsConnector symbol to ensure
it's available to consumers.
In `@packages/core/src/capabilities.ts`:
- Around line 44-48: The PLATFORM_ALLOWLIST map is being accessed with
inherited-property-sensitive checks; replace any checks like `platform in
PLATFORM_ALLOWLIST` or direct uses of `PLATFORM_ALLOWLIST[platform]` with an
own-property guard using
Object.prototype.hasOwnProperty.call(PLATFORM_ALLOWLIST, platform) before
reading the value to avoid prototype pollution (e.g., in the code that builds
`allowed`/new Set(allowed) and the other referenced checks). Update all
occurrences (the lookup that produces `allowed`, the presence check, and the
later usage that assumes a valid array) to return/handle a missing platform
safely when hasOwnProperty is false. Ensure you reference PLATFORM_ALLOWLIST and
the local `platform` variable when adding the guard.
---
Outside diff comments:
In `@packages/server/src/worker-api.ts`:
- Around line 154-186: The current capability filtering
(normalizeAdvertisedCapabilities -> authorizedCapabilities via
authorizeCapabilities) is never applied to actual claim checks: ensure
downstream checks use authorizedCapabilities (not advertisedCapabilities) and
that the legacy browser gate uses the stored/normalized hasBrowser derived from
the worker metadata (e.g., the hasBrowser variable computed from capabilities)
rather than the raw request body; update any code that references
advertisedCapabilities or reads browser from the request to instead reference
authorizedCapabilities and hasBrowser so user-scoped workers cannot claim
disallowed lanes or spoof browser=true.
---
Nitpick comments:
In `@apps/chrome/pairing.html`:
- Around line 72-95: The input `#gateway-url` lacks a visible/screen-reader label;
add one by inserting a <label for="gateway-url">Gateway URL</label> immediately
before the input (or, if you prefer no visible text, add a semantic
screen-reader-only label element or set aria-label="Gateway URL" on the input)
so assistive tech and keyboard users can identify the field; ensure the label
text matches the purpose (e.g., "Owletto server URL" or "Gateway URL") and keep
the input's id "gateway-url" as the for target.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4651f49d-841d-44f7-af05-3465487aeed1
📒 Files selected for processing (22)
apps/chrome/README.mdapps/chrome/SCOPE.mdapps/chrome/background.jsapps/chrome/bridge.jsapps/chrome/config.jsapps/chrome/manifest.jsonapps/chrome/pairing.htmlapps/chrome/pairing.jsapps/chrome/permissions.htmlapps/chrome/permissions.jsapps/chrome/sidepanel.htmlapps/chrome/sidepanel.jsapps/mac/Lobu.xcodeproj/project.pbxprojapps/mac/Lobu/ChromeBridgeHost.swiftapps/mac/Lobu/LobuApp.swiftpackages/connectors/src/chrome_tabs.tspackages/connectors/src/index.tspackages/core/src/capabilities.tspackages/core/src/index.tspackages/server/src/index.tspackages/server/src/worker-api.tspackages/web
| function startPolling() { | ||
| if (pollTimer) return; | ||
| pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS); | ||
| void pollOnce(); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Chrome Manifest V3 extensions, are service workers allowed to rely on setInterval/setTimeout for periodic background polling, or should chrome.alarms be used because workers can be terminated when idle?
💡 Result:
In Chrome Manifest V3 extensions, you should use the chrome.alarms API for periodic background polling rather than relying on setInterval or setTimeout [1][2][3]. Service workers in Manifest V3 are ephemeral and designed to be terminated by the browser when they are idle [2][4][5]. Chrome typically terminates a service worker after 30 seconds of inactivity [4][5][6]. Because these workers are shut down, any active setInterval or setTimeout timers are destroyed, meaning they will not fire as expected [1][2][3]. Key considerations for your implementation: 1. Reliability: The chrome.alarms API is managed by the browser itself [1]. If your service worker is terminated, the browser will automatically wake it up when the alarm fires, ensuring your code executes as scheduled [1][3]. 2. Limitations: The chrome.alarms API enforces a minimum period of 1 minute for production extensions [1][7]. If your use case requires sub-minute polling, you cannot rely on standard timers in the service worker [1][3]. In such cases, developers often use alternative strategies, such as maintaining a connection to an offscreen document or using WebSockets, though these approaches are more complex and must be designed to handle the service worker's lifecycle [1][4][5]. 3. Design Pattern: You should register your alarm listeners at the top level of your service worker script to ensure they are properly initialized when the worker wakes up [8][2]. In summary, while setInterval and setTimeout are technically available in the service worker environment, they are not suitable for periodic background tasks because they do not prevent the service worker from being terminated and will be cleared upon termination [2][9][3].
Citations:
- 1: https://coldfusion-example.blogspot.com/2026/03/how-to-fix-chrome-extension-manifest-v3.html
- 2: https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers
- 3: https://dev.to/_350df62777eb55e1/manifest-v3-migration-the-gotchas-nobody-warned-me-about-2imh
- 4: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
- 5: https://coldfusion-example.blogspot.com/2026/02/preventing-service-worker-termination.html
- 6: https://groups.google.com/a/chromium.org/g/chromium-extensions/c/L3EbiNMjIGI
- 7: https://developer.chrome.com/docs/extensions/mv2/reference/alarms
- 8: https://stackoverflow.com/questions/66629892/how-do-you-do-periodic-background-work-in-a-chrome-extension-using-manifest-v3
- 9: https://groups.google.com/a/chromium.org/g/chromium-extensions/c/ZkNgraHcsLI
🏁 Script executed:
find . -name "background.js" -type f | head -20Repository: lobu-ai/lobu
Length of output: 84
🏁 Script executed:
# Check if apps/chrome/background.js exists and view the polling section
if [ -f "apps/chrome/background.js" ]; then
echo "=== File exists. Lines 91-95: ==="
sed -n '91,95p' "apps/chrome/background.js"
echo ""
echo "=== Broader context (85-100): ==="
sed -n '85,100p' "apps/chrome/background.js"
fiRepository: lobu-ai/lobu
Length of output: 589
🏁 Script executed:
# Check for manifest.json to confirm MV3
find . -path "*chrome/manifest.json" -o -path "*chrome*/manifest.json" | head -5 | xargs catRepository: lobu-ai/lobu
Length of output: 737
Use chrome.alarms instead of setInterval for the poll loop.
MV3 service workers are transient. Once Chrome suspends this worker, this interval stops firing, so background polling can stall until some unrelated event wakes the extension again. The extension already declares the alarms permission, so the fix is straightforward.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/chrome/background.js` around lines 91 - 95, Replace the
setInterval-based loop in startPolling with chrome.alarms: stop using
pollTimer/setInterval, create an alarm (e.g. name "poll") via
chrome.alarms.create using periodInMinutes = POLL_INTERVAL_MS / 60000, and
register chrome.alarms.onAlarm listener to call pollOnce; still call pollOnce()
immediately for the first run. Also update any cleanup in stopPolling to
chrome.alarms.clear("poll") instead of clearInterval/clearing pollTimer, and
remove pollTimer usage throughout the module.
| case "requestOptionalPermission": { | ||
| const perm = stringOrThrow(params.permission, "permission"); | ||
| const granted = await chrome.permissions.request({ permissions: [perm] }); | ||
| return { granted }; |
There was a problem hiding this comment.
Whitelist requestable permissions at the bridge boundary.
requestOptionalPermission currently forwards any string from the hosted iframe into chrome.permissions.request(). That means every manifest optional permission becomes remotely requestable as soon as it is added later, which weakens the deny-by-default model this file is trying to enforce.
Suggested fix
+const ALLOWED_OPTIONAL_PERMISSIONS = new Set(["history", "bookmarks"]);
+
async function dispatch(op, params) {
switch (op) {
@@
case "requestOptionalPermission": {
const perm = stringOrThrow(params.permission, "permission");
+ if (!ALLOWED_OPTIONAL_PERMISSIONS.has(perm)) {
+ throw new Error("unknown_permission");
+ }
const granted = await chrome.permissions.request({ permissions: [perm] });
return { granted };
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/chrome/bridge.js` around lines 104 - 107, The requestOptionalPermission
branch in bridge.js currently forwards any string into
chrome.permissions.request; restrict this by implementing a whitelist of allowed
optional permissions (e.g., ALLOWED_OPTIONAL_PERMISSIONS constant) and validate
the perm (obtained via stringOrThrow) against that list inside the
"requestOptionalPermission" case before calling chrome.permissions.request; if
the permission is not in the whitelist, throw or return a clear error/denied
response so only explicitly allowed optional permissions can be requested from
the iframe.
| const granted = await chrome.permissions.request({ | ||
| origins: [`${cleanBase}/*`], | ||
| }); | ||
| if (!granted) { |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Chrome Manifest V3, does chrome.permissions.request()require a direct user gesture when requestingorigins, and what happens if it is called during page initialization?
💡 Result:
In Chrome Manifest V3, the chrome.permissions.request method requires a direct user gesture to function [1]. This requirement applies to all requests, including those for origins [1]. If you attempt to call chrome.permissions.request during page initialization (or any context not triggered by a direct user action, such as a click or keyboard shortcut), the call will fail [2][3][4]. Specifically, the API will set runtime.lastError to indicate that the function must be called during a user gesture [2][1][4]. To handle this, developers are advised to: 1. Ensure the request is initiated within an event handler for a user action, such as a button click in an extension popup or options page [3][1]. 2. If permissions are missing, inform the user via the UI (e.g., displaying a message or a button) and only trigger the request when the user interacts with that UI element [5][6]. 3. Use chrome.permissions.contains to check for existing permissions before attempting to request them, which can help avoid unnecessary or failed requests [2][6].
Citations:
- 1: https://developer.chrome.com/docs/extensions/reference/api/permissions
- 2: https://groups.google.com/a/chromium.org/g/chromium-extensions/c/u4txOB87_fo
- 3: https://stackoverflow.com/questions/76555077/extension-optional-permissions-request-what-counts-as-a-user-gesture
- 4: https://stackoverflow.com/questions/27669590/chrome-extension-function-must-be-called-during-a-user-gesture
- 5: https://stackoverflow.com/questions/78220903/how-to-request-host-permissions-at-install-time-with-manifest-v3
- 6: https://discourse.mozilla.org/t/request-permissions-at-install-time-manifest-v3/121543
Move chrome.permissions.request() call to a user gesture event handler.
Line 170 calls chrome.permissions.request() during page initialization, but Manifest V3 requires this API to be invoked only within a direct user gesture context (e.g., button click). Calling it at page load will fail and set runtime.lastError, preventing the permission grant. Trigger the request from a user action instead.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/chrome/pairing.js` around lines 170 - 173, The
chrome.permissions.request call is being invoked during initialization causing
it to fail; move that call into a direct user-gesture handler (e.g., create a
click handler like handleRequestPermissions or attach to an existing button's
onclick) and call chrome.permissions.request({ origins: [`${cleanBase}/*`] })
only from that handler; after moving, keep the existing granted check and also
inspect chrome.runtime.lastError to handle and log failures from the permission
request.
| if (!/^https?:$/.test(url.protocol)) { | ||
| setupStatus.textContent = "URL must use http:// or https://."; | ||
| return; |
There was a problem hiding this comment.
Reject plaintext HTTP except localhost loopback.
Allowing arbitrary http:// here risks sending OAuth/device tokens over plaintext transport to remote hosts. Restrict to https:// and allow http:// only for local development loopback.
🔒 Proposed fix
- if (!/^https?:$/.test(url.protocol)) {
- setupStatus.textContent = "URL must use http:// or https://.";
+ const isLoopbackHost =
+ url.hostname === "localhost" ||
+ url.hostname === "127.0.0.1" ||
+ url.hostname === "::1" ||
+ url.hostname === "[::1]";
+ const isAllowed =
+ url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHost);
+ if (!isAllowed) {
+ setupStatus.textContent =
+ "Use https:// for remote servers. http:// is only allowed for localhost.";
return;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!/^https?:$/.test(url.protocol)) { | |
| setupStatus.textContent = "URL must use http:// or https://."; | |
| return; | |
| const isLoopbackHost = | |
| url.hostname === "localhost" || | |
| url.hostname === "127.0.0.1" || | |
| url.hostname === "::1" || | |
| url.hostname === "[::1]"; | |
| const isAllowed = | |
| url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHost); | |
| if (!isAllowed) { | |
| setupStatus.textContent = | |
| "Use https:// for remote servers. http:// is only allowed for localhost."; | |
| return; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/chrome/pairing.js` around lines 208 - 210, The current check allows any
http: URL; change the validation so only https: is accepted except when the URL
is an http: loopback for local development. Replace the
/^https?:$/.test(url.protocol) gate with a strict check: accept when
url.protocol === 'https:' OR when url.protocol === 'http:' AND url.hostname is a
loopback host (e.g., 'localhost', '127.0.0.1', IPv4 127.* via regex, or IPv6
'::1'/'[::1]'). Update the error message accordingly via the same
setupStatus.textContent branch and return when validation fails; locate and
modify the conditional around url.protocol and setupStatus.textContent in
pairing.js.
| const deadline = Date.now() + (authz.expires_in ?? 600) * 1000; | ||
| let intervalMs = Math.max((authz.interval ?? 5) * 1000, 1000); | ||
|
|
||
| pollTimer = setInterval(async () => { | ||
| if (Date.now() > deadline) { | ||
| clearInterval(pollTimer); | ||
| pollStatus.textContent = "Code expired. Try again."; | ||
| return; | ||
| } | ||
| let response; | ||
| try { | ||
| response = await fetchJson(discovery.token_endpoint, { | ||
| grant_type: "urn:ietf:params:oauth:grant-type:device_code", | ||
| client_id: client.client_id, | ||
| device_code: authz.device_code, | ||
| ...(client.client_secret | ||
| ? { client_secret: client.client_secret } | ||
| : {}), | ||
| }); | ||
| } catch (err) { | ||
| pollStatus.textContent = `Failed: ${err.message}`; | ||
| return; | ||
| } | ||
| if (response.status === "pending") { | ||
| pollStatus.textContent = "Waiting for approval…"; | ||
| if (response.error === "slow_down") intervalMs += 5000; | ||
| return; | ||
| } | ||
| if (response.status !== "ok") { | ||
| clearInterval(pollTimer); | ||
| pollStatus.textContent = `Failed: ${response.error ?? response.status}`; | ||
| return; | ||
| } | ||
|
|
||
| clearInterval(pollTimer); | ||
|
|
||
| const workerId = | ||
| (await chrome.storage.local.get(STORAGE_KEYS.workerId))[ | ||
| STORAGE_KEYS.workerId | ||
| ] ?? crypto.randomUUID(); | ||
|
|
||
| await chrome.storage.local.set({ | ||
| [STORAGE_KEYS.workerId]: workerId, | ||
| [STORAGE_KEYS.accessToken]: response.tokens.access_token, | ||
| [STORAGE_KEYS.refreshToken]: response.tokens.refresh_token ?? null, | ||
| [STORAGE_KEYS.clientId]: client.client_id, | ||
| [STORAGE_KEYS.clientSecret]: client.client_secret ?? null, | ||
| [STORAGE_KEYS.pairedAt]: Date.now(), | ||
| }); | ||
| pollStatus.textContent = "Paired ✓"; | ||
| setTimeout(() => window.close(), 800); | ||
| }, intervalMs); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does setInterval(async () => {...}, n)wait for the async callback before scheduling the next tick, and what is the recommended OAuth device-code polling pattern for handlingslow_down dynamically?
💡 Result:
No, setInterval does not wait for an asynchronous callback to complete before scheduling the next tick [1][2]. It schedules the next execution based on the timer interval regardless of how long the previous callback takes to execute [3][2]. If an asynchronous callback takes longer than the interval, it can lead to overlapping executions or a backlog of tasks [3][2]. For tasks requiring sequential execution (where the next task should only start after the previous one finishes), the recommended pattern is to use a recursive setTimeout or an async/await loop [1][2]. Regarding the OAuth 2.0 Device Authorization Grant (RFC 8628), the recommended polling pattern for handling the slow_down error is as follows: 1. Initial Polling: Start polling the token endpoint at the interval specified in the initial device authorization response [4][5]. 2. Handling slow_down: If the authorization server returns a slow_down error, the client must increase the polling interval [6]. According to RFC 8628, the client should add 5 seconds to the current interval [7][6]. 3. Implementation: Maintain a dynamic interval variable that is updated whenever a slow_down response is received [7]. Use a loop (e.g., while loop with an async sleep function) to manage the polling, ensuring that each request is completed and the appropriate delay is respected before the next request is initiated [2][7]. This approach ensures compliance with the specification and prevents the client from being rate-limited or blocked by the authorization server [6].
Citations:
- 1: https://stackoverflow.com/questions/52184291/async-await-with-setinterval
- 2: https://sangwin.medium.com/why-developers-avoid-using-setinterval-in-javascript-852996b43d7a
- 3: https://stackoverflow.com/questions/70749273/do-nodejs-setintervals-queue-up
- 4: https://www.ory.com/docs/oauth2-oidc/device-authorization
- 5: https://darutk.medium.com/illustrated-device-flow-rfc-8628-d23d6d311acc
- 6: Device code flow not respecting slow_down cli/oauth#76
- 7: https://oneuptime.com/blog/post/2026-01-24-oauth2-device-authorization-flow/view
🏁 Script executed:
# First, locate and verify the file exists
find . -name "pairing.js" -type fRepository: lobu-ai/lobu
Length of output: 81
🏁 Script executed:
# Read the file to verify content at lines 295-347
wc -l ./apps/chrome/pairing.jsRepository: lobu-ai/lobu
Length of output: 85
🏁 Script executed:
# Read lines 295-347 to verify the snippet matches
sed -n '295,347p' ./apps/chrome/pairing.jsRepository: lobu-ai/lobu
Length of output: 1825
🏁 Script executed:
# Check startBtn usage in the file
rg -n "startBtn" ./apps/chrome/pairing.jsRepository: lobu-ai/lobu
Length of output: 222
🏁 Script executed:
# Check the context around the polling code - lines 250-300
sed -n '250,300p' ./apps/chrome/pairing.jsRepository: lobu-ai/lobu
Length of output: 1592
🏁 Script executed:
# Check the full function context and pollTimer declaration
sed -n '255,388p' ./apps/chrome/pairing.jsRepository: lobu-ai/lobu
Length of output: 4282
🏁 Script executed:
# Check if pollTimer is declared at module level
head -100 ./apps/chrome/pairing.js | grep -n "pollTimer"Repository: lobu-ai/lobu
Length of output: 127
🏁 Script executed:
# Verify the error handling paths in the current code - check lines around 295-360
sed -n '295,360p' ./apps/chrome/pairing.js | cat -nRepository: lobu-ai/lobu
Length of output: 2658
Polling loop has overlapping-request and slow_down handling bugs.
setInterval(async ...) does not wait for the async callback to complete before scheduling the next tick, allowing requests to overlap under latency. Additionally, incrementing intervalMs on slow_down has no effect on the already-scheduled interval, violating RFC 8628 pacing requirements and potentially hammering the token endpoint.
Convert to a recursive setTimeout pattern to ensure single-flight requests and allow dynamic interval updates:
Proposed fix
- pollTimer = setInterval(async () => {
+ const pollOnce = async () => {
if (Date.now() > deadline) {
- clearInterval(pollTimer);
+ if (pollTimer) clearTimeout(pollTimer);
pollStatus.textContent = "Code expired. Try again.";
+ startBtn.disabled = false;
return;
}
+
let response;
try {
response = await fetchJson(discovery.token_endpoint, {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: client.client_id,
device_code: authz.device_code,
...(client.client_secret
? { client_secret: client.client_secret }
: {}),
});
} catch (err) {
pollStatus.textContent = `Failed: ${err.message}`;
+ startBtn.disabled = false;
return;
}
+
if (response.status === "pending") {
pollStatus.textContent = "Waiting for approval…";
if (response.error === "slow_down") intervalMs += 5000;
- return;
+ pollTimer = setTimeout(() => {
+ void pollOnce();
+ }, intervalMs);
+ return;
}
+
if (response.status !== "ok") {
- clearInterval(pollTimer);
+ if (pollTimer) clearTimeout(pollTimer);
pollStatus.textContent = `Failed: ${response.error ?? response.status}`;
+ startBtn.disabled = false;
return;
}
- clearInterval(pollTimer);
+ if (pollTimer) clearTimeout(pollTimer);
const workerId =
(await chrome.storage.local.get(STORAGE_KEYS.workerId))[
STORAGE_KEYS.workerId
] ?? crypto.randomUUID();
@@
pollStatus.textContent = "Paired ✓";
setTimeout(() => window.close(), 800);
- }, intervalMs);
+ };
+
+ void pollOnce();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/chrome/pairing.js` around lines 295 - 347, The polling loop using
setInterval (pollTimer) causes overlapping requests and ignores runtime changes
to intervalMs (e.g., when response.error === "slow_down"); replace it with a
recursive setTimeout-based loop (a function like pollOnce or doPoll) that awaits
fetchJson, checks Date.now() > deadline, updates intervalMs when response.error
=== "slow_down", and schedules the next poll via setTimeout(nextPoll,
intervalMs) only after the current request completes; ensure you still clear any
outstanding timer when pairing succeeds or expires and keep using existing
symbols (pollTimer, intervalMs, deadline, discovery.token_endpoint, fetchJson,
pollStatus, STORAGE_KEYS) so storage writes and UI updates remain unchanged.
| window.addEventListener("message", (ev) => { | ||
| if (!embeddedOrigin || ev.origin !== embeddedOrigin) return; | ||
| const { id: requestedId, op, params } = ev.data ?? {}; | ||
| if (typeof op !== "string") return; | ||
| const id = `req-${nextId++}`; | ||
| pending.set(id, { requestedId, source: ev.source }); | ||
| port.postMessage({ id, op, params, origin: embeddedOrigin }); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
For secure use of window.postMessage with one known iframe, is checking event.origin alone sufficient, or should event.source === iframe.contentWindow also be enforced?
💡 Result:
For secure use of window.postMessage, checking event.origin is the primary and mandatory security requirement [1][2][3]. Relying on event.origin ensures that you only process messages from domains you explicitly trust [1][4]. While checking event.source === iframe.contentWindow can provide an additional layer of verification by confirming the specific window instance, it is not a substitute for origin validation [5][6]. In many scenarios, checking the origin is sufficient, but verifying the source is recommended if you need to distinguish between multiple windows or iframes that share the same origin [6]. Key security best practices include: 1. Always validate event.origin: Never assume the sender is who you expect without verifying the origin against a strict allowlist [1][2][3]. 2. Use specific targetOrigin: When calling postMessage, always specify the exact target origin instead of using the wildcard "*" [1][7][3]. 3. Validate message data: Even after verifying the origin, always validate the structure and content of the received message to protect against potential XSS or logic flaws [1][2]. 4. Avoid unnecessary listeners: If you do not expect to receive messages from other sites, do not add a message event listener at all [5][7]. In summary, checking event.origin is essential for security [2][3]. Adding a check for event.source is a good practice for tighter coupling with a specific iframe, but it should be used in conjunction with, not instead of, origin validation [6].
Citations:
- 1: https://html.spec.whatwg.org/multipage/web-messaging.html
- 2: https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
- 3: https://bindbee.dev/blog/secure-cross-window-communication
- 4: https://medium.com/somos-pragma/an-alternative-for-sending-data-to-another-place-or-to-localstorage-postmessage-dc7e72e7ea11
- 5: https://stackoverflow.com/questions/20526308/safe-use-of-window-postmessage-is-if-event-source-windowreturn-alri
- 6: https://blog.teknkl.com/you-should-probably-be-checking-istrusted-in-messageevent-listeners/
- 7: https://blog.securelayer7.net/postmessage-common-issues-and-how-you-can-mitigate-them/
🏁 Script executed:
wc -l apps/chrome/sidepanel.jsRepository: lobu-ai/lobu
Length of output: 84
🏁 Script executed:
cat -n apps/chrome/sidepanel.jsRepository: lobu-ai/lobu
Length of output: 3225
Bind messages to the specific iframe instance, not just its origin.
Origin-only filtering accepts messages from any same-origin popup or nested frame. Since this listener is the privilege boundary into extension APIs, add ev.source === frame.contentWindow to verify the message comes from the exact iframe instance:
Suggested fix
window.addEventListener("message", (ev) => {
- if (!embeddedOrigin || ev.origin !== embeddedOrigin) return;
+ if (
+ !embeddedOrigin ||
+ ev.origin !== embeddedOrigin ||
+ ev.source !== frame.contentWindow
+ ) {
+ return;
+ }
const { id: requestedId, op, params } = ev.data ?? {};
if (typeof op !== "string") return;
const id = `req-${nextId++}`;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/chrome/sidepanel.js` around lines 60 - 67, The message listener
currently only checks ev.origin (embeddedOrigin) which allows any same-origin
window to send messages; modify the handler in window.addEventListener to also
verify the sender matches the specific iframe by checking ev.source ===
frame.contentWindow before processing. Update the conditional that gates
processing (the branch that references embeddedOrigin and ev.origin) to include
ev.source equality to frame.contentWindow so pending.set(...),
port.postMessage(...), and id generation (nextId) only occur for messages from
that exact iframe instance.
| static func runHostIfRequested() { | ||
| let args = CommandLine.arguments | ||
| guard args.count >= 2, args[1].hasPrefix("chrome-extension://") else { | ||
| return | ||
| } | ||
| #if DEBUG | ||
| FileHandle.standardError.write("[bridge] runHostIfRequested entry\n".data(using: .utf8)!) | ||
| #endif | ||
| let exitCode = NativeMessagingLoop.run() | ||
| #if DEBUG | ||
| FileHandle.standardError.write("[bridge] runHostIfRequested exit=\(exitCode)\n".data(using: .utf8)!) | ||
| #endif | ||
| exit(exitCode) | ||
| } |
There was a problem hiding this comment.
Validate the invoking extension ID before serving the bridge.
runHostIfRequested() trusts any chrome-extension://... argv. A local process can launch the Lobu binary directly with a fake origin and obtain a freshly minted child token; the manifest's allowed_origins only protects Chrome's normal launch path. Please reject callers not in the same allowlist used for installManifests().
Also applies to: 69-77
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/mac/Lobu/ChromeBridgeHost.swift` around lines 46 - 59,
runHostIfRequested currently accepts any argv beginning with
"chrome-extension://" which allows local processes to spoof origins; update
runHostIfRequested to parse the extension ID from CommandLine.arguments[1] and
validate it against the same allowlist used by installManifests() (e.g., pull or
reference the canonical allowedOrigins set or validation helper used when
creating manifests); if the origin is not in the allowlist, return without
calling NativeMessagingLoop.run and avoid emitting tokens. Apply the same
validation logic to the other host-entry path noted in the review (the alternate
entry that runs at the 69-77 region) so both entry points reject non-allowed
extension IDs before starting the native messaging loop.
| export * from './local_directory.ts'; | ||
| export * from './browser-scraper-utils.ts'; | ||
| export * from './capterra.ts'; | ||
| export * from './chrome_tabs.ts'; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does export * from "./module" re-export that module's default export in ES modules / TypeScript?
💡 Result:
No, the export * from "./module" statement does not re-export the default export of the referenced module in ES modules or TypeScript [1][2]. The export * from ... syntax only re-exports the named exports of the target module [1][3]. Because the default export is treated as a special named export (internally bound to the name default), it is explicitly excluded from the export * aggregation to prevent accidental collisions and to maintain predictable module behavior [1][2]. To re-export a default export, you must explicitly export it by name [3][4]. Common patterns include: 1. Re-exporting the default as a named export: export { default } from "./module"; [1][3] 2. Re-exporting the default and renaming it: export { default as MyComponent } from "./module"; [3][5] 3. Combining both approaches to re-export everything, including the default: export * from "./module"; export { default } from "./module"; [1]
Citations:
- 1: https://stackoverflow.com/questions/57580560/is-it-possible-to-export-and-default-from-another-module-in-a-single-statement
- 2: https://esdiscuss.org/topic/re-export-default
- 3: https://stackoverflow.com/questions/39999282/re-export-default-in-es-6-modules
- 4: https://esmodules.com/cheatsheet/
- 5: https://jamesknelson.com/re-exporting-es6-modules/
🏁 Script executed:
cat -n packages/connectors/src/chrome_tabs.tsRepository: lobu-ai/lobu
Length of output: 3137
This barrel does not export the new connector.
chrome_tabs.ts only exports ChromeTabsConnector as a default export. The export * statement re-exports only named exports and explicitly excludes default exports, so consumers importing from the package entrypoint won't see ChromeTabsConnector.
Proposed fix
-export * from './chrome_tabs.ts';
+export { default as ChromeTabsConnector } from './chrome_tabs.ts';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export * from './chrome_tabs.ts'; | |
| export { default as ChromeTabsConnector } from './chrome_tabs.ts'; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/connectors/src/index.ts` at line 7, The barrel currently uses
"export * from './chrome_tabs.ts'" which doesn't re-export the default export
ChromeTabsConnector, so consumers can't import it from the package root; update
the package entry barrel to explicitly re-export the default as a named export
(e.g., add an export line that exports ChromeTabsConnector from chrome_tabs.ts)
or change chrome_tabs.ts to export ChromeTabsConnector as a named export and
then keep the existing export * line—target the index.ts barrel and the
ChromeTabsConnector symbol to ensure it's available to consumers.
| const PLATFORM_ALLOWLIST: Record<string, readonly string[]> = { | ||
| macos: [...OS_CAPABILITIES, ...BROWSER_CAPABILITIES], | ||
| ios: IOS_CAPABILITIES, | ||
| "chrome-extension": BROWSER_CAPABILITIES, | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does JavaScript's inoperator on a plain object return true for inherited properties liketoString, and can obj["toString"]resolve toObject.prototype.toString?
💡 Result:
Yes, the JavaScript in operator returns true for inherited properties, including toString, when used on a plain object [1][2][3]. The in operator checks for the existence of a property in an object or anywhere along its prototype chain [2][4]. Since standard plain objects inherit from Object.prototype, and Object.prototype contains the toString method, the expression "toString" in obj will evaluate to true for any plain object [3][5]. Regarding property access, obj["toString"] does resolve to Object.prototype.toString [6]. When you access a property on an object, JavaScript first checks if the object has that property as an "own" property [6][7]. If it does not, the engine traverses the prototype chain—looking at the object's prototype, then the prototype's prototype, and so on—until it finds the property or reaches the end of the chain (null) [6][7]. Because plain objects inherit from Object.prototype, they successfully resolve and can execute the inherited toString method [6][5].
Citations:
- 1: https://thelinuxcode.com/difference-between-in-and-hasownproperty-in-javascript-and-what-to-use-in-2026/
- 2: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/in
- 3: https://dev.to/hijazi313/the-javascript-in-operator-a-powerful-tool-you-might-be-overlooking-2i5a
- 4: https://dev.mozilla.org/en-US/docs/Web/JavaScript/Guide/Enumerability_and_ownership_of_properties
- 5: https://stackoverflow.com/questions/18640776/does-every-object-in-js-have-a-tostring-method
- 6: https://stackoverflow.com/questions/63308262/how-does-this-object-see-its-inherited-properties
- 7: https://www.geeksforgeeks.org/javascript/object-inherited-property/
🏁 Script executed:
cat -n packages/core/src/capabilities.tsRepository: lobu-ai/lobu
Length of output: 3419
🏁 Script executed:
find . -maxdepth 3 -name "tsconfig*.json" -o -name "package.json" | head -10Repository: lobu-ai/lobu
Length of output: 398
🏁 Script executed:
find packages/core -name "tsconfig*.json" -o -name "package.json" | xargs cat 2>/dev/null | head -100Repository: lobu-ai/lobu
Length of output: 1866
Use own-property checks for the platform registry to prevent prototype pollution attacks.
PLATFORM_ALLOWLIST[platform] and platform in PLATFORM_ALLOWLIST both check inherited properties. A request with platform: "toString" would resolve to Object.prototype.toString (a function), causing new Set(allowed) on line 68 to throw. Use Object.prototype.hasOwnProperty.call() for own-property checks since the target is ES2020 (which does not support Object.hasOwn()).
Proposed fix
-const PLATFORM_ALLOWLIST: Record<string, readonly string[]> = {
+const PLATFORM_ALLOWLIST: Record<string, readonly string[]> = Object.assign(Object.create(null), {
macos: [...OS_CAPABILITIES, ...BROWSER_CAPABILITIES],
ios: IOS_CAPABILITIES,
"chrome-extension": BROWSER_CAPABILITIES,
-};
+});
export function authorizeCapabilities(
platform: string | null | undefined,
declared: readonly string[]
): CapabilityAuthorizationResult {
- const allowed = platform ? PLATFORM_ALLOWLIST[platform] : undefined;
+ const allowed =
+ platform && Object.prototype.hasOwnProperty.call(PLATFORM_ALLOWLIST, platform)
+ ? PLATFORM_ALLOWLIST[platform]
+ : undefined;
if (!allowed) {
return { authorized: [], dropped: [...declared] };
}
@@
export function isKnownPlatform(platform: string | null | undefined): boolean {
- return !!platform && platform in PLATFORM_ALLOWLIST;
+ return !!platform && Object.prototype.hasOwnProperty.call(PLATFORM_ALLOWLIST, platform);
}Also applies to: 64-68, 81-82
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/src/capabilities.ts` around lines 44 - 48, The
PLATFORM_ALLOWLIST map is being accessed with inherited-property-sensitive
checks; replace any checks like `platform in PLATFORM_ALLOWLIST` or direct uses
of `PLATFORM_ALLOWLIST[platform]` with an own-property guard using
Object.prototype.hasOwnProperty.call(PLATFORM_ALLOWLIST, platform) before
reading the value to avoid prototype pollution (e.g., in the code that builds
`allowed`/new Set(allowed) and the other referenced checks). Update all
occurrences (the lookup that produces `allowed`, the presence check, and the
later usage that assumes a valid array) to return/handle a missing platform
safely when hasOwnProperty is false. Ensure you reference PLATFORM_ALLOWLIST and
the local `platform` variable when adding the guard.
…-connector # Conflicts: # packages/web
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Addresses three HIGH-impact issues + one MEDIUM correctness bug surfaced in code review: 1. (HIGH) Capability authorization was bypassed when claiming runs. pollWorkerJob's user-scoped claim SQL filtered on `advertisedCapabilities` (the raw declared set), so a device claiming `os.shell` would have it dropped for storage but could still match and claim a connector run requiring it. Switched the two device branches in the claim SQL to `authorizedCapabilities` (the post- allowlist set). 2. (HIGH) `mintDeviceChildToken` only checked `c.var.user?.id`, so any authenticated session that mcpAuth accepts could escalate into a worker token. Now requires the caller's bearer to already carry the `device_worker:run` scope — i.e. only an existing device-worker token (or a PAT minted for that purpose) can mint a sibling. Browser sessions can't silently escalate; users wanting to pair Chrome from a browser go through OAuth device-authorization, not this endpoint. 3. (MEDIUM) Native-messaging readFrame trusted the wire-level 32-bit length. A buggy or malicious allowed extension could hang or memory-pressure the host on a crafted header. Added a 64 KB cap on the payload length (our pair request is ~50 bytes; this is generous). 4. (MEDIUM) Extension's `executeRun` sent the wrong field names to /api/workers/complete (`items_count`, `error`) — server expects `items_collected`, `error_message`. Tab-snapshot runs were completing as 0-item successes and failures dropped their diagnostic. SCOPE.md documents three additional review notes as follow-ups: the native host's argv check needs a stronger same-user defense, child PATs aren't yet bound to their `worker_id`, and the extension still advertises capabilities ahead of executors. Typecheck + xcode build clean.
…rame cap
Re-review surfaced two remaining gaps from the previous security pass:
1. (HIGH) Capability authorization trusted self-reported `platform` on
every poll. A compromised chrome-extension PAT could post
`platform: "macos"` and unlock the macOS allowlist — claim
`os.shell` runs the device can't actually execute. Fix has three
parts:
- Poll handler reads the stored platform for (user_id, worker_id);
if it exists and differs from the posted one, rejects 403
`platform_mismatch`.
- UPSERT on conflict now COALESCE-preserves device_workers.platform
(defense-in-depth against a race between SELECT and UPSERT).
- Capability authorization uses the stored platform when present,
not the body-posted one.
- `mintDeviceChildToken` pre-creates the device_workers row with
`platform` set, so the very first poll from a freshly-minted
child already finds the platform locked.
2. (MEDIUM, re-introduced by an earlier edit revert) The 64 KB cap on
the native-messaging frame length had been silently dropped. Restored
`maxFramePayloadBytes` + bounds check in ChromeBridgeHost.readFrame.
Typecheck + xcode build clean. The non-blocking review notes remain
documented in apps/chrome/SCOPE.md.
Closes the last platform-binding bypass: a chrome-extension PAT could post a fresh, never-seen `worker_id` plus `platform: "macos"` and register a new macOS-platform device_workers row under the same user. The pre-existing-row platform lock didn't help because the row didn't exist yet. Fix: `personal_access_tokens` now has a `worker_id` column. PATs minted via /api/me/devices/mint-child-token carry the worker_id they're bound to. The worker-poll handler reads the bound id from `mcpAuthInfo` and rejects requests whose body posts a different one (403 `worker_id_mismatch`). Legacy paths (OAuth-issued bearers for the Mac/iOS bridges, the existing CLI PATs) keep `worker_id = NULL`; the poll handler treats NULL as "no binding" so they can register any worker_id they pick on first poll — same as today. - db/migrations/20260517020000_pat_worker_id_binding.sql: nullable column + partial index. - db/schema.sql: matching column + index entries. - auth/oauth/types.ts: AuthInfo.workerId optional. - auth/tokens.ts: create() accepts workerId option; verify() returns it. - worker-api.ts (pollWorkerJob): rejects on mismatch before the capability-authorization step. - worker-api.ts (mintDeviceChildToken): passes workerId to create().
The bound-token check skipped when body's worker_id was empty (falsy). Compare unconditionally; also require non-empty worker_id from any user-scoped caller. Otherwise a bound chrome-extension PAT could post worker_id='' and have the binding check + platform lock skipped, registering under (user_id, '') with attacker-chosen platform.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@db/schema.sql`:
- Around line 4001-4007: Regenerate the canonical schema.sql from the migrated
database so it matches the real post-migration state: ensure the
personal_access_tokens table includes the worker_id column comment (the same
comment added by the migration for column worker_id on table
personal_access_tokens) and add the migration version row 20260517020000 into
public.schema_migrations, and verify the existing index
idx_personal_access_tokens_worker_id is present and unchanged.
In `@packages/server/src/auth/tokens.ts`:
- Around line 36-42: The code currently coerces a blank string workerId into
null using `workerId || null`, which causes an empty `''` to be treated as
unbound; update all places that do this (references around `workerId` in this
file, including the child-token/mint path) to only convert undefined to null and
preserve empty strings — e.g. replace `workerId || null` with an explicit check
like `workerId === undefined ? null : workerId` (or equivalent) so that `''` is
stored as `''` and only missing values become null.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ab188df9-abb9-44c8-8b11-f4649425a85b
📒 Files selected for processing (5)
db/migrations/20260517020000_pat_worker_id_binding.sqldb/schema.sqlpackages/server/src/auth/oauth/types.tspackages/server/src/auth/tokens.tspackages/server/src/worker-api.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/server/src/worker-api.ts
| /** | ||
| * Bind the PAT to a specific device_workers.worker_id. The | ||
| * worker-poll handler will reject requests whose body posts a | ||
| * different worker_id. Used by /api/me/devices/mint-child-token to | ||
| * pin sibling-device tokens to the worker_id minted for them. | ||
| */ | ||
| workerId?: string; |
There was a problem hiding this comment.
Don't coerce a blank workerId into an unbound PAT.
Line 66 uses || null, so '' gets stored as NULL. On the child-token path that silently drops the worker binding this PR is adding.
Suggested fix
+ const workerId = options?.workerId;
+ if (workerId !== undefined && workerId.trim() === '') {
+ throw new Error('workerId must be non-empty when provided');
+ }
+
const result = await this.sql`
INSERT INTO personal_access_tokens (
token_hash, token_prefix, user_id, organization_id,
name, description, scope, expires_at, worker_id
) VALUES (
@@
- ${expiresAt},
- ${options?.workerId || null}
+ ${expiresAt},
+ ${workerId ?? null}
)Also applies to: 63-66
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/auth/tokens.ts` around lines 36 - 42, The code currently
coerces a blank string workerId into null using `workerId || null`, which causes
an empty `''` to be treated as unbound; update all places that do this
(references around `workerId` in this file, including the child-token/mint path)
to only convert undefined to null and preserve empty strings — e.g. replace
`workerId || null` with an explicit check like `workerId === undefined ? null :
workerId` (or equivalent) so that `''` is stored as `''` and only missing values
become null.
A Mac bridge advertises 'screentime', 'local_directory', 'healthkit', 'photos', 'whatsapp_local' from AppState.swift:caps. The macos allowlist only had os.* + browser.*, so these would all get dropped server-side and Mac connectors would stop matching their device. Add a LEGACY_MAC_CAPABILITIES export that the macos entry includes — keeps existing Mac functionality intact while still excluding cross-platform claims (a Mac PAT can't suddenly claim 'browser.history' via authorization, only via actually having the chrome-extension already covers it under its own platform binding).
…gration format Three follow-ups from the security re-review: 1. (HIGH) `worker_id: ''` bypassed the bound-token check (empty string was falsy in the previous guard). pollWorkerJob now requires a non-empty `worker_id` from any user-scoped caller and compares the bound id unconditionally — no truthiness short-circuit. 2. (HIGH regression) macos capability allowlist dropped 'screentime', 'local_directory', 'healthkit', 'photos', 'whatsapp_local' — the existing Mac bridge advertises these and its connectors would have silently stopped matching. Adds MAC_DEVICE_CAPABILITIES alongside OS_/BROWSER_CAPABILITIES. 3. CI: migration was missing `-- migrate:down`; lint-format job failed. Added the down section + matching teardown. 4. Submodule bump: pulls in lobu-ai/owletto#138 — adds /embedded to the all-routes smoke test (was missing in #137).
Main's #775 added a migration at 20260517020000; bumping mine to 20260517030000 so file order is unambiguous.
…-connector # Conflicts: # apps/mac/Lobu.xcodeproj/project.pbxproj
Summary
Adds Owletto for Chrome — an MV3 extension that pairs a Chrome profile to Owletto/Lobu, advertises a capability set to the gateway, and (when the Owletto Mac app is installed and signed in) auto-pairs with zero second login via Chrome native messaging.
Architecture
@lobu/core/capabilities). Device workers self-report aplatform+ capability strings; the gateway intersects the declared set with a platform-specific allowlist. A chrome-extension can claimbrowser.tabsbut notos.shell; the trusted-fleet path is unchanged.device_workers.platformwas already free-form TEXT — Chrome just claimschrome-extensionand existing reconciliation auto-wires bundled connectors that match its advertised capabilities.chrome.runtime.connectNative("ai.owletto.bridge")→ Mac binary spawned as a subprocess → reads OAuth bearer from keychain → calls newPOST /api/me/devices/mint-child-token→ returns{gateway_url, worker_id, access_token}. Zero clicks.Layout
packages/core/src/capabilities.ts— platform → allowed-capabilities registry +authorizeCapabilities().packages/server/src/worker-api.ts— intersects declared caps with the registry;mintDeviceChildTokenendpoint.packages/connectors/src/chrome_tabs.ts— minimalrequiredCapability: "browser.tabs"connector that lists open tabs astab_snapshotevents.apps/chrome/— MV3 extension. ~1.3k LOC:background.js(poll loop + run executor),pairing.{html,js}(URL setup + OAuth + native-messaging),bridge.js(typed postMessage broker between sidepanel iframe and service worker, named-ops + origin-checked + deny-by-default),permissions.{html,js}(opt-in toggles forhistory/bookmarks),sidepanel.{html,js}(iframe of owletto-web/embedded).apps/mac/Lobu/ChromeBridgeHost.swift— Chrome native-messaging host.runHostIfRequested()detects Chrome's argv signature (chrome-extension://…as argv[1]) and runs a single 4-byte-length-prefixed stdin/stdout JSON cycle;installManifests()drops the host manifest into Chrome/Brave/Arc/EdgeNativeMessagingHostsdirs.packages/webcarries the new/embeddedroute (lobu-ai/owletto-web#137).Scope discipline
Excluded from v1 (tracked in
apps/chrome/SCOPE.md):chrome.cookies, Firefox/Safari, Edge/Brave/Arc auto-install via External Extensions, per-tab automation overlay, real run executor (heartbeat / multi-batch / action runs / checkpointing — the current executor handles a single batch forchrome.tabs, fails fast for anything else).Test plan
make typecheckcleanxcodebuild -project apps/mac/Lobu.xcodeproj -scheme Lobu -configuration Debug build→ BUILD SUCCEEDEDdevice_workersrow appears withplatform=chrome-extensionand the authorized capability set.POST /api/me/devices/mint-child-tokenreturns 200 + worker token (curl)apps/chrome/README.md).Notes
keyfield is not pinned, so the unpacked dev extension ID isn't stable. The Mac app's installer readsLOBU_OWLETTO_CHROME_EXTENSION_IDfrom the env to populateallowed_origins. Once the extension ships to the Web Store its ID can be hardcoded.Summary by CodeRabbit
New Features
Documentation