Skip to content

feat(server,cli): auth.md discovery + lobu login --email headless claim#1073

Merged
buremba merged 3 commits into
mainfrom
feat/agent-claim-discovery
May 26, 2026
Merged

feat(server,cli): auth.md discovery + lobu login --email headless claim#1073
buremba merged 3 commits into
mainfrom
feat/agent-claim-discovery

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 26, 2026

What

Makes the user_claimed agent-registration flow (shipped in #1071) discoverable and usable, on both the HTTP and CLI front doors.

Server — auth.md discovery surface:

  • agent_auth block in the OAuth authorization-server metadata (flows_supported, claim_methods_supported, registration_endpoint, device_authorization_endpoint, claim_email_endpoint, token_endpoint, auth_md). Only user_claimed is advertised; the ID-JAG agent_verified flow is deliberately absent.
  • auth_md pointer in the protected-resource metadata (RFC 9728 allows extra members).
  • GET /auth.md serving an agent-readable walkthrough, generated from the deployment base URL so it's correct for self-hosted installs.

CLI — lobu login --email <address>:

  • Reuses the entire device-code login path (register → device_authorization → poll → save credentials). The only new behaviour: it POSTs the user_code + email to the discovered claim endpoint instead of printing a code, and keeps polling without a TTY because approval rides the emailed link.
  • This is the headless front door for an agent to log in on a user's behalf with no pre-minted PAT. discoverOAuth now parses agent_auth.claim_email_endpoint; --email errors clearly if the server doesn't advertise it.

This closes the gap where the new claim flow existed only as a raw HTTP endpoint — lobu login had no mode for it, so it looked like agents couldn't log in headlessly. Now they can.

Why this shape

The email claim is the same device-code grant lobu login already runs; the only difference is approval delivery (emailed link vs terminal code). So the CLI change is glue over existing, tested machinery, and the HTTP discovery lets non-CLI agents (OpenAI/Cursor) find the flow via standard OAuth metadata.

Test evidence

  • Server integration (agent-claim-discovery.test.ts, 4/4 green vs real PG17+pgvector under Node 22): the agent_auth block, the PRM auth_md pointer, GET /auth.md, and the full user_claimed loop — register → device_authorization → device/email → consent approve (as the signed-in user) → token poll yields a scoped credential.
  • CLI unit (oauth.test.ts, 31/31 via bun test): discoverOAuth parses agent_auth.claim_email_endpoint and leaves it undefined when absent.

Still deferred

The ID-JAG agent_verified zero-touch flow, and the owletto consent-page copy for the agent-initiated context (separate cross-repo PR).

Summary by CodeRabbit

  • New Features

    • CLI login adds a headless email-claim mode via a new --email option for device approval; interactive behavior preserved.
    • Server advertises agent registration and email-claim capabilities and exposes an auth.md walkthrough endpoint.
  • Documentation

    • Added an authentication guide documenting interactive, CI/token, local run, and headless email-approval flows and linking to auth.md.
  • Tests

    • Added unit and integration tests for discovery, email-claim behavior, and end-to-end claim/approval flows.

Review Change Stack

Server: advertise the auth.md user_claimed flow. Add an agent_auth block to
the OAuth authorization-server metadata (flows_supported, claim_email_endpoint,
device_authorization_endpoint, token_endpoint, auth_md), an auth_md pointer in
the protected-resource metadata, and GET /auth.md serving the agent-readable
walkthrough generated from the deployment base URL.

CLI: add 'lobu login --email <address>'. Reuses the whole device-code login
path (register -> device_authorization -> poll -> save), but POSTs the
user_code+email to the discovered claim endpoint instead of printing a code,
and keeps polling without a TTY because approval rides the emailed link. This
is the headless front door an agent uses to log in on a user's behalf without a
pre-minted PAT. discoverOAuth now parses agent_auth.claim_email_endpoint.

Tests: server discovery + full user_claimed loop (register -> device/email ->
consent approve -> token), CLI discovery parsing. Skill documents the flow.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 043dddf8-12c9-4de9-b02f-fb4adb6e9314

📥 Commits

Reviewing files that changed from the base of the PR and between 9d9c180 and da52b36.

📒 Files selected for processing (1)
  • packages/cli/src/commands/login.ts

📝 Walkthrough

Walkthrough

This PR implements a headless "email-claim" device-code login: the CLI can POST {user_code, email} to a discovered claim endpoint, then poll /oauth/token until approval; the server advertises agent_auth metadata and serves an auth.md walkthrough; tests cover discovery, CLI flows, and end-to-end approval/token issuance.

Changes

Email-Claim Headless Device-Code Authentication

Layer / File(s) Summary
OAuth Discovery Extension for Claim Endpoint
packages/cli/src/internal/oauth.ts, packages/cli/src/internal/__tests__/oauth.test.ts
OAuthDiscovery gains optional claimEmailEndpoint; discovery parsing extracts agent_auth.claim_email_endpoint when present and tests verify both presence and absence cases.
CLI Email-Claim Device-Code Flow
packages/cli/src/commands/login.ts
LoginOptions adds email?: string. After device authorization, when email is provided the CLI validates discovery.claimEmailEndpoint, POSTs { user_code, email } via sendEmailClaim, prints a waiting message, and continues polling for token approval even in non-TTY contexts; sendEmailClaim parses JSON error details and throws OAuthError("email_claim_failed", ...) on non-OK responses.
CLI Email Flag Integration
packages/cli/src/index.ts
Adds --email <address> flag and forwards the parsed email into loginCommand invocation.
Server Metadata & Auth Documentation Endpoint
packages/server/src/auth/oauth/auth-md.ts, packages/server/src/auth/oauth/provider.ts, packages/server/src/auth/oauth/routes.ts
Authorization/protected-resource discovery now advertise agent_auth with supported flows and claim endpoints; buildAuthMd(baseUrl) generates the auth.md walkthrough and new GET /auth.md serves it as text/markdown.
Integration Tests & Authentication Documentation
packages/server/src/__tests__/integration/oauth/agent-claim-discovery.test.ts, skills/lobu/SKILL.md
Integration test verifies .well-known metadata, /auth.md content, and a full user_claimed loop (device-code issuance, email claim POST -> 202, session approval, token exchange). User docs updated with an Authentication section referencing the new flow and auth.md.
CLI Unit/Integration Tests for Email Flow
packages/cli/src/__tests__/login-email.test.ts
Adds tests that mock discovery and flow: verify the CLI calls the claim endpoint, continues polling, saves credentials on success, and correctly handles discovery without agent_auth by exiting with code 1.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • lobu-ai/lobu#914: Also modifies device-code polling and non-interactive pending handling; overlapping area in polling behavior changes.
  • lobu-ai/lobu#1071: Adds the server-side POST /oauth/device/email endpoint consumed by this CLI email-claim flow.

Poem

🐰 I hopped to press the CLI's key,

Sent a code and asked it kindly,
A link flew out to an email chest,
Approval clicked — the token blessed,
Rabbit danced: headless login, kindly!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely and accurately captures the main changes: introducing auth.md discovery and the lobu login --email headless claim flow.
Description check ✅ Passed The description comprehensively covers the What, Why, and Test evidence. It includes sections explaining the feature, the rationale, and integration/unit test results. All required template sections are present and filled out.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/agent-claim-discovery

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 26, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 58.33333% with 35 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/cli/src/commands/login.ts 54.66% 34 Missing ⚠️
packages/cli/src/index.ts 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 26, 2026

bug_free 86, simplicity 84, slop 5, bugs 0, 0 blockers

Suites passed. Explored CLI bin help -> --email listed; buildAuthMd emits user_claimed + device/email. Skipped full server boot; integration hit metadata and full user_claimed loop. Local worktree has formatter-only edits in 2 CLI tests not part of HEAD.

Suggested fixes

File Line Change
packages/cli/src/commands/login.ts 122 For --email, check discovery.claimEmailEndpoint before registerClient/startDeviceAuthorization so unsupported servers fail without creating a client/device-code request; remove the later duplicate support check.
Full verdict JSON
{
  "bug_free_confidence": 86,
  "bugs": 0,
  "slop": 5,
  "simplicity": 84,
  "blockers": [],
  "change_type": "feat",
  "behavior_change_risk": "medium",
  "tests_adequate": true,
  "suggested_fixes": [
    {
      "file": "packages/cli/src/commands/login.ts",
      "line": 122,
      "change": "For --email, check discovery.claimEmailEndpoint before registerClient/startDeviceAuthorization so unsupported servers fail without creating a client/device-code request; remove the later duplicate support check."
    }
  ],
  "notes": "Suites passed. Explored CLI bin help -> --email listed; buildAuthMd emits user_claimed + device/email. Skipped full server boot; integration hit metadata and full user_claimed loop. Local worktree has formatter-only edits in 2 CLI tests not part of HEAD.",
  "categories": {
    "src": 278,
    "tests": 337,
    "docs": 9,
    "config": 0,
    "deps": 0,
    "migrations": 0,
    "ci": 0,
    "generated": 0
  }
}

Local review gate — branch protection can require the pi-review commit status. See docs/REVIEW_SCHEMA.md.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/cli/src/commands/login.ts`:
- Around line 135-152: The code currently uses Boolean(options.email) so passing
--email "" or whitespace bypasses explicit-mode checks; change email detection
to check option presence and non-empty trimmed content (e.g., treat emailClaim
as options.email !== undefined) and then validate that options.email.trim() is
not empty before proceeding; if empty, print the same error/usage message and
set process.exitCode = 1; only call sendEmailClaim (via tryOAuthStep) with the
validated trimmed value (references: options.email, emailClaim, sendEmailClaim,
tryOAuthStep, discovery.claimEmailEndpoint, authorization.userCode).
🪄 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: db4d4f97-7407-44fb-bf28-6d150cc5dcfb

📥 Commits

Reviewing files that changed from the base of the PR and between 12ec940 and 26ec508.

📒 Files selected for processing (9)
  • packages/cli/src/commands/login.ts
  • packages/cli/src/index.ts
  • packages/cli/src/internal/__tests__/oauth.test.ts
  • packages/cli/src/internal/oauth.ts
  • packages/server/src/__tests__/integration/oauth/agent-claim-discovery.test.ts
  • packages/server/src/auth/oauth/auth-md.ts
  • packages/server/src/auth/oauth/provider.ts
  • packages/server/src/auth/oauth/routes.ts
  • skills/lobu/SKILL.md

Comment thread packages/cli/src/commands/login.ts Outdated
Comment on lines +135 to +152
const emailClaim = Boolean(options.email);
if (emailClaim) {
if (!discovery.claimEmailEndpoint) {
console.log(
chalk.red(
`\n ${discovery.issuer} does not support email login (no agent_auth.claim_email_endpoint).`
)
);
console.log(chalk.dim(" Use plain `lobu login` or `--token <pat>`.\n"));
process.exitCode = 1;
return;
}
const sent = await tryOAuthStep(() =>
sendEmailClaim(
discovery.claimEmailEndpoint as string,
authorization.userCode,
options.email as string
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat --email as explicit mode and validate non-empty input.

Line 135 uses Boolean(options.email), so an explicit --email "" can drop into interactive mode, and whitespace-only values are sent as-is. Gate on option presence and validate trimmed content before calling the claim endpoint.

Proposed patch
-  const emailClaim = Boolean(options.email);
+  const emailClaim = options.email !== undefined;
+  const email = options.email?.trim();
   if (emailClaim) {
+    if (!email) {
+      console.log(chalk.red("\n  --email requires a non-empty address.\n"));
+      process.exitCode = 1;
+      return;
+    }
     if (!discovery.claimEmailEndpoint) {
       console.log(
         chalk.red(
           `\n  ${discovery.issuer} does not support email login (no agent_auth.claim_email_endpoint).`
         )
       );
       console.log(chalk.dim("  Use plain `lobu login` or `--token <pat>`.\n"));
       process.exitCode = 1;
       return;
     }
     const sent = await tryOAuthStep(() =>
       sendEmailClaim(
         discovery.claimEmailEndpoint as string,
         authorization.userCode,
-        options.email as string
+        email
       )
     );
     if (!sent) return;
     console.log(
       chalk.dim(
-        `\n  Sent a confirmation link to ${chalk.white(options.email as string)}.`
+        `\n  Sent a confirmation link to ${chalk.white(email)}.`
       )
     );
🤖 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/cli/src/commands/login.ts` around lines 135 - 152, The code
currently uses Boolean(options.email) so passing --email "" or whitespace
bypasses explicit-mode checks; change email detection to check option presence
and non-empty trimmed content (e.g., treat emailClaim as options.email !==
undefined) and then validate that options.email.trim() is not empty before
proceeding; if empty, print the same error/usage message and set
process.exitCode = 1; only call sendEmailClaim (via tryOAuthStep) with the
validated trimmed value (references: options.email, emailClaim, sendEmailClaim,
tryOAuthStep, discovery.claimEmailEndpoint, authorization.userCode).

tryOAuthStep returns the callback value or undefined on error; sendEmailClaim
resolved void, so the success result read as falsy and the command returned
before polling — the email sent but no credential was ever collected. Return a
truthy sentinel from the wrapped call. Add a regression test that drives the
full --email flow (claim -> poll -> save) and the no-claim-endpoint error path.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/cli/src/commands/login.ts (1)

135-136: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle --email as explicit mode and validate trimmed input before claim.

Boolean(options.email) still allows explicit empty input to fall back to interactive mode, and whitespace-only values can be sent/logged untrimmed.

Suggested patch
-  const emailClaim = Boolean(options.email);
+  const emailClaim = options.email !== undefined;
+  const email = options.email?.trim();
   if (emailClaim) {
+    if (!email) {
+      console.log(chalk.red("\n  --email requires a non-empty address.\n"));
+      process.exitCode = 1;
+      return;
+    }
     if (!discovery.claimEmailEndpoint) {
       console.log(
         chalk.red(
           `\n  ${discovery.issuer} does not support email login (no agent_auth.claim_email_endpoint).`
         )
       );
       console.log(chalk.dim("  Use plain `lobu login` or `--token <pat>`.\n"));
       process.exitCode = 1;
       return;
     }
     const sent = await tryOAuthStep(async () => {
       await sendEmailClaim(
         discovery.claimEmailEndpoint as string,
         authorization.userCode,
-        options.email as string
+        email
       );
       return true;
     });
     if (!sent) return;
     console.log(
       chalk.dim(
-        `\n  Sent a confirmation link to ${chalk.white(options.email as string)}.`
+        `\n  Sent a confirmation link to ${chalk.white(email)}.`
       )
     );

Also applies to: 154-154, 161-161

🤖 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/cli/src/commands/login.ts` around lines 135 - 136, The current check
using Boolean(options.email) treats an explicit --email "" as false and allows
whitespace-only values; change the detection to treat the flag as explicit when
the option is present (e.g., options.email !== undefined or
Object.prototype.hasOwnProperty.call(options, 'email')) and set emailClaim from
that, then trim the provided value (const rawEmail =
options.email?.toString().trim() ?? '') and validate it: if emailClaim is true
but rawEmail is empty or only whitespace, surface a validation error (or prompt)
instead of falling back to interactive mode; apply the same change to the other
places referencing options.email/emailClaim in this module (e.g., where
emailClaim is checked and where the email value is used).
🤖 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.

Duplicate comments:
In `@packages/cli/src/commands/login.ts`:
- Around line 135-136: The current check using Boolean(options.email) treats an
explicit --email "" as false and allows whitespace-only values; change the
detection to treat the flag as explicit when the option is present (e.g.,
options.email !== undefined or Object.prototype.hasOwnProperty.call(options,
'email')) and set emailClaim from that, then trim the provided value (const
rawEmail = options.email?.toString().trim() ?? '') and validate it: if
emailClaim is true but rawEmail is empty or only whitespace, surface a
validation error (or prompt) instead of falling back to interactive mode; apply
the same change to the other places referencing options.email/emailClaim in this
module (e.g., where emailClaim is checked and where the email value is used).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fb64abaa-7dad-4100-a1b8-5b1b072a8186

📥 Commits

Reviewing files that changed from the base of the PR and between 26ec508 and 9d9c180.

📒 Files selected for processing (2)
  • packages/cli/src/__tests__/login-email.test.ts
  • packages/cli/src/commands/login.ts

…ice code

Move the --email claimEmailEndpoint check ahead of registerClient/
startDeviceAuthorization so an unsupported server fails fast without leaving an
orphan OAuth client + device-code row; drop the now-duplicate later check.
(pi review polish, non-blocking.)
@buremba buremba merged commit 56fbe94 into main May 26, 2026
@buremba buremba deleted the feat/agent-claim-discovery branch May 26, 2026 13:46
buremba added a commit that referenced this pull request May 26, 2026
These landed format-dirty via #1073 (the pre-commit --write didn't reach the
committed blob), turning the format-lint check red on main. Expand the inline
arrays/objects to match `biome format`. Verified clean with the exact CI
command (biome format --config-path config/biome.config.json .). No logic
change. Folded into this pointer-bump PR since it was blocked on the same red.
buremba added a commit that referenced this pull request May 26, 2026
* chore: bump packages/owletto pointer to 2ffdefa

Picks up: fix(oauth): frame the device consent page as an access request when arriving via a link (#224)

Before: b3d3a64
After:  2ffdefa

* style(cli): biome-format login-email + oauth tests

These landed format-dirty via #1073 (the pre-commit --write didn't reach the
committed blob), turning the format-lint check red on main. Expand the inline
arrays/objects to match `biome format`. Verified clean with the exact CI
command (biome format --config-path config/biome.config.json .). No logic
change. Folded into this pointer-bump PR since it was blocked on the same red.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants