feat(server,mac): no-auth mode for embedded server (LOBU_NO_AUTH=1)#779
Conversation
Replaces the closed PR #777 ("lift the bootstrap PAT") with the cleaner Phase B from docs/plans/personal-mode-auth.md: server short-circuits auth when LOBU_NO_AUTH=1, attributes every request to the local user ensureBootstrapPat() seeded. The macOS menu bar spawns the runner with that env set, then sets credentials directly — no PAT to read, no verification call, no ownership tracking. Server (packages/server): - multi-tenant.ts: getNoAuthUser() loads the bootstrap-user + their personal org once and caches; resolveAuth() short-circuits with owner-role attribution when LOBU_NO_AUTH=1. URL-supplied org slug must match the local user's org (single-org by definition). - start-local.ts: post-listen bind assertion refuses to serve on anything other than 127.0.0.1 / ::1 when LOBU_NO_AUTH=1. Surfaces a hard error early instead of silently exposing the local user's data. Mac app (apps/mac/Lobu): - LocalLobuRunner sets LOBU_NO_AUTH=1 in the spawn env alongside LOBU_DATA_DIR. - AppState.connect() — when targeting the managed runner, calls adoptLocalCredentials() with synthesised OAuthCredentials (dummy bearer; server ignores it). No PAT file, no userinfo verification, no spawnedThisSession check — none of that is needed when the server itself bypasses auth. - MenuBarContent button reads "Start" / "Connect" for managed-runner URLs. User experience: click Start once. Server spawns with no-auth env, popover transitions to signed-in within ~1 s. No browser, no code, no approval. The dummy bearer the menu bar sends is never validated; it exists only so the existing WorkerClient/Authorization scaffolding doesn't have to learn a "no header" mode. Defers (still in docs/plans/personal-mode-auth.md): - CSRF middleware on mutating routes — browser-tab exfiltration risk remains until we ship Origin / Sec-Fetch-Site / Host / Content-Type checks. Today no-auth mode trusts that the loopback bind is the only attack surface. - Per-user data dir / port for shared macOS user accounts.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughThe PR implements a loopback-only no-auth mode for the embedded local Lobu runner: the macOS app detects managed local-runner URLs and adopts synthesized no-auth credentials (persisted), the runner is started with LOBU_NO_AUTH=1 and tracked per-session, client requests include ChangesNo-auth Local Runner
Sequence DiagramsequenceDiagram
participant User
participant MacApp as Mac App (AppState)
participant LocalRunner as LocalLobuRunner
participant Server as Lobu Server
participant Auth as MultiTenant Auth
User->>MacApp: open managed local runner URL
MacApp->>MacApp: detect managed URL, adoptLocalCredentials()
MacApp->>LocalRunner: start lobu run with LOBU_NO_AUTH=1
LocalRunner->>Server: child process binds and serves
Server->>Server: isLoopbackHost validation (pre/post-listen)
Server->>Auth: incoming request routed to resolveAuth()
Auth->>Auth: getNoAuthUser() -> populate auth context
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 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 docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23f19e78b2
ℹ️ 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".
| // No-auth mode: server attributes every request to the local user | ||
| // without an OAuth flow, a token, or a sign-in screen. Personal-use | ||
| // model — see docs/plans/personal-mode-auth.md for the threat model. | ||
| env["LOBU_NO_AUTH"] = "1" |
There was a problem hiding this comment.
Set HOST when enabling no-auth
When the mac app spawns lobu run it now sets LOBU_NO_AUTH=1, but it never overrides the embedded server's default HOST, which is still 0.0.0.0 in packages/server/src/start-local.ts. The new no-auth startup guard rejects any non-loopback host, so the normal Start path (lobu run --port 8787 from the menu bar with no HOST env) exits before becoming ready instead of launching the local server; set HOST=127.0.0.1 here or make the server default loopback in no-auth mode.
Useful? React with 👍 / 👎.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/mac/Lobu/AppState.swift (1)
402-417:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftRequire a positive no-auth signal before persisting dummy credentials.
matchesManagedRunner()only proves “this is localhost:8787”.LocalLobuRunner.start()will also adopt any already-running Lobu on that port, so this branch can skip OAuth against a normal auth-enabled server and savenoauthcredentials anyway. From there, poll/search/notifications all talk to the wrong auth model and fail. Only take this path after the server proves it is running in no-auth mode, or after attaching to a process this app spawned itself.🤖 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/AppState.swift` around lines 402 - 417, The code assumes matchesManagedRunner(url) implies a no-auth server and unconditionally calls adoptLocalCredentials and sets serverMode to .local; instead require a positive no-auth confirmation or proof we spawned the process before persisting dummy credentials. Modify the branch around matchesManagedRunner/startLocalLobu: after startLocalLobu or when localLobuStatus.isRunning is true, perform a lightweight probe (e.g., call an auth-check endpoint or implement isNoAuthServer(url)) or check a flag that indicates the runner was started by our process (e.g., localLobuStatus.startedByUs); only if that probe returns no-auth OR startedByUs is true, call adoptLocalCredentials(baseURL:) and set serverMode = .local and startAutoPollIfSignedIn(); otherwise treat it as .remote and do not persist noauth credentials.
🤖 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 `@packages/server/src/start-local.ts`:
- Around line 197-223: When LOBU_NO_AUTH=1 is enabled the process currently
exits if HOST is not loopback; instead detect noAuth (process.env.LOBU_NO_AUTH
=== '1') and, if HOST is unset or is the non-loopback default (e.g. '0.0.0.0' /
'::'), set HOST to a loopback address (e.g. '127.0.0.1') before calling
httpServer.listen so the documented env-only flow works; retain the
isLoopbackHost(addr.address) post-listen safety check and use the same effective
HOST value for the listen() call and logger messages (references: HOST, noAuth,
isLoopbackHost, httpServer.listen, logger, PORT, DATA_DIR).
In `@packages/server/src/workspace/multi-tenant.ts`:
- Around line 122-150: getNoAuthUser currently picks the first owner/admin
membership for 'bootstrap-user' which can return the wrong org; narrow the query
to the seeded personal organization created by ensureBootstrapPat(). Modify
getNoAuthUser (or its call sites) to obtain the bootstrap personal org id (via
ensureBootstrapPat() or a helper that returns the seeded org id) and add a
filter m."organizationId" = <bootstrapPersonalOrgId> (or join the organization
table and filter to the seeded personal org) so the query only returns the
bootstrap-user row for the intended personal org; update the signature of
getNoAuthUser if needed to accept that org id and adjust callers accordingly.
---
Outside diff comments:
In `@apps/mac/Lobu/AppState.swift`:
- Around line 402-417: The code assumes matchesManagedRunner(url) implies a
no-auth server and unconditionally calls adoptLocalCredentials and sets
serverMode to .local; instead require a positive no-auth confirmation or proof
we spawned the process before persisting dummy credentials. Modify the branch
around matchesManagedRunner/startLocalLobu: after startLocalLobu or when
localLobuStatus.isRunning is true, perform a lightweight probe (e.g., call an
auth-check endpoint or implement isNoAuthServer(url)) or check a flag that
indicates the runner was started by our process (e.g.,
localLobuStatus.startedByUs); only if that probe returns no-auth OR startedByUs
is true, call adoptLocalCredentials(baseURL:) and set serverMode = .local and
startAutoPollIfSignedIn(); otherwise treat it as .remote and do not persist
noauth credentials.
🪄 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: 3dd85295-6832-4e15-bd5a-7230e11978a9
📒 Files selected for processing (5)
apps/mac/Lobu/AppState.swiftapps/mac/Lobu/LocalLobuRunner.swiftapps/mac/Lobu/MenuBarContent.swiftpackages/server/src/start-local.tspackages/server/src/workspace/multi-tenant.ts
| // No-auth mode is loopback-only by design. Refuse to listen on anything | ||
| // other than 127.0.0.1 / ::1 — both via the configured HOST (early fail) | ||
| // and via a post-listen `server.address()` check that catches surprises | ||
| // (DNS resolution, hostname aliasing, etc.). | ||
| const noAuth = process.env.LOBU_NO_AUTH === '1'; | ||
| if (noAuth && !isLoopbackHost(HOST)) { | ||
| logger.error( | ||
| { host: HOST }, | ||
| 'LOBU_NO_AUTH=1 requires loopback bind (127.0.0.1 or ::1). Refusing to start.' | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| httpServer.listen(PORT, HOST, () => { | ||
| if (noAuth) { | ||
| const addr = httpServer.address(); | ||
| if (typeof addr === 'object' && addr && !isLoopbackHost(addr.address)) { | ||
| logger.error( | ||
| { address: addr.address }, | ||
| 'LOBU_NO_AUTH=1 server bound to a non-loopback address after listen() — refusing to serve.' | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| logger.info(`Lobu running at http://${HOST}:${PORT}`); | ||
| logger.info(`Data: ${DATA_DIR}`); | ||
| if (noAuth) logger.info('No-auth mode active (LOBU_NO_AUTH=1) — every request attributed to local user'); | ||
| }); |
There was a problem hiding this comment.
LOBU_NO_AUTH=1 currently rejects the default startup path.
HOST still falls back to 0.0.0.0 above, so enabling no-auth without also setting HOST exits here before listen(). That blocks the documented env-only flow and the new macOS launcher path unless another layer knows about this extra contract. Default the no-auth case to a loopback bind, then use that same effective host for listen() and logging.
💡 Suggested fix
- const noAuth = process.env.LOBU_NO_AUTH === '1';
- if (noAuth && !isLoopbackHost(HOST)) {
+ const noAuth = process.env.LOBU_NO_AUTH === '1';
+ const effectiveHost = noAuth && !process.env.HOST?.trim() ? '127.0.0.1' : HOST;
+ if (noAuth && !isLoopbackHost(effectiveHost)) {
logger.error(
- { host: HOST },
+ { host: effectiveHost },
'LOBU_NO_AUTH=1 requires loopback bind (127.0.0.1 or ::1). Refusing to start.'
);
process.exit(1);
}
- httpServer.listen(PORT, HOST, () => {
+ httpServer.listen(PORT, effectiveHost, () => {
if (noAuth) {
const addr = httpServer.address();
if (typeof addr === 'object' && addr && !isLoopbackHost(addr.address)) {
logger.error(
{ address: addr.address },
@@
- logger.info(`Lobu running at http://${HOST}:${PORT}`);
+ logger.info(`Lobu running at http://${effectiveHost}:${PORT}`);🤖 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/start-local.ts` around lines 197 - 223, When
LOBU_NO_AUTH=1 is enabled the process currently exits if HOST is not loopback;
instead detect noAuth (process.env.LOBU_NO_AUTH === '1') and, if HOST is unset
or is the non-loopback default (e.g. '0.0.0.0' / '::'), set HOST to a loopback
address (e.g. '127.0.0.1') before calling httpServer.listen so the documented
env-only flow works; retain the isLoopbackHost(addr.address) post-listen safety
check and use the same effective HOST value for the listen() call and logger
messages (references: HOST, noAuth, isLoopbackHost, httpServer.listen, logger,
PORT, DATA_DIR).
Server (packages/server): - HOST default of 0.0.0.0 conflicted with the no-auth bind guard. The Mac runner now explicitly sets HOST=127.0.0.1 in the spawn env (#1), and server.ts also gets the same loopback bind guard so an accidental LOBU_NO_AUTH=1 in production refuses to start instead of silently bypassing auth on the public bind (#5). - ensureBootstrapPat now runs BEFORE httpServer.listen so the very first request can't 503 due to the seeding race (#3). The early-return now trusts the DB row, not the bootstrap-pat.txt file: a wiped LOBU_DATA_DIR with a leftover file used to leave no-auth permanently 503; now we re-mint the user/org/PAT (#4). Production safety guard still skips when OTHER (non-bootstrap) users exist. - getNoAuthUser pins to the BOOTSTRAP_USER_ID + BOOTSTRAP_ORG_ID pair directly, not "first owner/admin membership LIMIT 1" — eliminates the nondeterminism if bootstrap-user ever gets cross-org memberships (#7). - isLoopbackHost moved to packages/server/src/utils/loopback.ts so both start-local and server share it. Handles the full IPv4 loopback /8, ::1, [::1], and IPv4-mapped IPv6 loopback (::ffff:127.x.y.z) (#8). - New CSRF middleware in index.ts. Only fires when LOBU_NO_AUTH=1. On mutating methods (POST/PUT/PATCH/DELETE) requires: * Host header is a loopback alias (defeats DNS rebinding). * Origin or Sec-Fetch-Site says same-origin/none, OR a custom X-Lobu-Client header is present (native clients omit Origin). * Content-Type, if set, must be application/json — defeats CSRF simple-request form posts that browsers allow without preflight. This is the only protection between the no-auth bypass and any malicious site the user visits in their browser, so it MUST be on whenever no-auth is on (#6). Mac app (apps/mac/Lobu): - LocalLobuRunner: pass HOST=127.0.0.1 alongside LOBU_NO_AUTH=1, and restore spawnedThisSession tracking so adoptLocalCredentials can refuse adoption when start() adopted a pre-existing server instead of spawning one. A malicious squatter or someone else's lobu run would otherwise receive our synthesised credentials (#2). - WorkerClient sends X-Lobu-Client: menubar on every request so the new CSRF middleware accepts native client traffic that legitimately omits Origin (#6).
…RF holes
Pi's verification of the previous fixup commit caught three remaining
issues:
1. AppState's init re-spawns the runner and starts polling using the
persisted no-auth credentials WITHOUT checking spawnedThisSession.
If a squatter happened to win :8787 on startup, the polling client
would send our synthesised "noauth" bearer + X-Lobu-Client to it.
Now: after startLocalLobu in init, if we didn't actually spawn the
process, clear the persisted creds and stop. The user will see the
connection card again and can sign in via OAuth.
2. ensureBootstrapPat checked only the user row's existence before the
early-return. Partial state (user exists, org or member rows missing)
would still wedge getNoAuthUser forever. Now we check all three rows
together — any missing one triggers a re-mint.
3. CSRF middleware: tightened in three ways.
- Missing Content-Type on a mutation is now rejected (was previously
a bypass — `if (ct && ...)` skipped when ct was empty).
- WorkerClient.markNotificationRead now sends Content-Type:
application/json even with an empty body to satisfy the tightened
check.
- OAuthClient.postRawJSON and ChromeBridgeHost.mintChildToken now
send X-Lobu-Client: menubar so they aren't rejected for missing
Origin in no-auth mode.
- Host header validation reuses the shared isLoopbackHost util
(stripping port + brackets first) so the alias set is consistent
with the bind-time enforcement.
PR #779 introduced no-auth mode but left three rough edges in the Mac app side of the spawn: - HOST=127.0.0.1 wasn't set in the runner env. start-local.ts defaults HOST=0.0.0.0, and the no-auth bind guard refuses non-loopback, so Start would silently crash the runner on boot. - ENCRYPTION_KEY isn't set on personal installs and the embedded gateway refuses to boot without it (or LOBU_ALLOW_EPHEMERAL_ENCRYPTION_KEY=1). For a personal install the ephemeral key is the only sensible default — opt in here so the user doesn't manage a secret manually. - The bundled lobu CLI requires Node 22.x–24.x (isolated-vm constraint) but a user on Node 25+ via Homebrew's `node` formula would crash the runner. Prefer well-known Node 22 install paths (Homebrew keg-only `node@22`, mise, fnm) by prepending them to PATH for the spawn. Also cleans up two related UX issues: - AppState.connect() now auto-fires on launch when credentials are nil and the URL targets the managed runner — no Start button click needed for the 99% case. The connection card only surfaces on actual failure (CLI missing, port conflict, etc). - startLocalLobu()'s failure handler no longer echoes the runner error into state.status. The connection card's connectStatusLine already shows it in orange under the button; echoing into state.status duplicates the message at the top of the popover.
The conflict-update early-return path called upsertEmbedding(existing.id, ...) before the SELECT that confirms the row is still there. If the row got deleted/tombstoned in that window, upsertEmbedding would FK-fail with a confusing error instead of letting us fall through to a fresh insert. Reorder so the embedding upsert only fires when the reread confirms the event still exists. Fall-through behavior on the race is unchanged. The 3 other CodeRabbit comments on this PR were against code from PR #779 (now merged) — those will land as separate follow-ups since they're out of scope here.
The conflict-update early-return path called upsertEmbedding(existing.id, ...) before the SELECT that confirms the row is still there. If the row got deleted/tombstoned in that window, upsertEmbedding would FK-fail with a confusing error instead of letting us fall through to a fresh insert. Reorder so the embedding upsert only fires when the reread confirms the event still exists. Fall-through behavior on the race is unchanged. The 3 other CodeRabbit comments on this PR were against code from PR #779 (now merged) — those will land as separate follow-ups since they're out of scope here.
* fix(insert-event): guard against empty INSERT RETURNING `insertEvent` assumed `INSERT ... RETURNING` always yields a row, then dereferenced `result[0].id` to feed `upsertEmbedding`. When the result came back empty — exposed by the menu bar's no-auth sync round-trip completing for the first time — the call crashed with the cryptic `TypeError: Cannot read properties of undefined (reading 'id')` and the worker stream returned 500. Two defensive guards: 1. After the main INSERT path, treat `result[0]` as possibly undefined. Log connector/feed/origin context and throw a real error explaining the row wasn't persisted. Callers (worker streamContent) catch the throw and surface a useful message instead of the stack-trace blob. 2. In the conflict-update early-return path, `existingRows[0]` could also be undefined if the found row was deleted/tombstoned between `findCurrentEventByOrigin` and the subsequent reread (race). Fall through to the fresh-insert path instead of crashing. Root cause of the original empty-result is still under investigation — likely a PGlite quirk with the new `search_tsv tsvector GENERATED ALWAYS AS (...) STORED` column from PR #765, or a transient state during bootstrap-org/connector wire-up. Either way, the right behavior is to fail loud with context rather than crash on `undefined.id`. * fix(insert-event): move embedding upsert after reread (CodeRabbit catch) The conflict-update early-return path called upsertEmbedding(existing.id, ...) before the SELECT that confirms the row is still there. If the row got deleted/tombstoned in that window, upsertEmbedding would FK-fail with a confusing error instead of letting us fall through to a fresh insert. Reorder so the embedding upsert only fires when the reread confirms the event still exists. Fall-through behavior on the race is unchanged. The 3 other CodeRabbit comments on this PR were against code from PR #779 (now merged) — those will land as separate follow-ups since they're out of scope here.
Summary
Replaces closed PR #777 ("lift the bootstrap PAT") with the cleaner Phase B from `docs/plans/personal-mode-auth.md`. The server short-circuits auth when `LOBU_NO_AUTH=1` and attributes every request to the local user that `ensureBootstrapPat` already creates on first boot. The macOS menu bar spawns the runner with that env, then synthesises credentials directly — no PAT to read, no verification call, no ownership tracking.
Why this over the PAT-lifting approach
Pi's iterative review of PR #777 surfaced enough corners in the lift-the-token frame (server-supplied URL exfiltration, PAT-not-accepted by /userinfo, squatter on :8787 receiving the bearer, file-vs-DB consistency, save-failure handling) that the no-token frame is less code overall and has a cleaner mental model: "server in personal mode treats every request as the local user, period."
Server changes
Mac app changes
Test plan
Defers (still in design doc)
Summary by CodeRabbit
New Features
Security
Bug Fixes