Skip to content

✨ feat: device-flow OAuth for gh and lark-cli#121

Merged
vaayne merged 21 commits intomainfrom
feat/device-flow-oauth
Apr 22, 2026
Merged

✨ feat: device-flow OAuth for gh and lark-cli#121
vaayne merged 21 commits intomainfrom
feat/device-flow-oauth

Conversation

@vaayne
Copy link
Copy Markdown
Owner

@vaayne vaayne commented Apr 21, 2026

Summary

  • Adds device-flow OAuth so users can connect their GitHub and Lark CLI credentials from the Profile page, stored encrypted in vault
  • Injects GH_TOKEN / LARKSUITE_CLI_* env vars into sandbox sessions at startup so raw gh and lark-cli shell commands from agents work transparently
  • Provisions POSIX shell wrapper scripts (~/.anna/bin/gh, ~/.anna/bin/lark-cli) prepended to PATH; Docker sessions get container-translated wrapper PATH + fixed ANNA_GH_BIN/ANNA_LARK_BIN absolute paths to avoid exec loops
  • Adds gh 2.89.0 and lark-cli 1.0.15 to the sandbox Docker image

Phases

Phase Change
1 plugins/auth/github + plugins/auth/lark — admin config plugins for OAuth app credentials
2 internal/oauthcli/ — device-flow broker, token manager, vault bundle serialization
3 internal/admin/oauth.go — profile API routes + UI section on the Profile page
4 internal/cliwrap/ — wrapper provisioning; runner env injection + bundle key filtering
5 Docker PATH injection via ANNA_WRAPPER_DIR; gh + lark-cli in Dockerfile
6 Unit tests (oauthcli, cliwrap, runner, docker); feature docs (EN + ZH)

Known limitations

  • Lark tokens expire in ~2 hours — start a new Anna session to refresh; documented in user-facing docs
  • In-flight device flows lost on restart — flow state is in-memory only; documented as v1 limitation
  • Token refresh for Lark happens at session start only

Test plan

  • mise run test — all tests pass (pre-existing Docker container conflict in internal/sandbox is unrelated)
  • mise run build — builds clean
  • Manually: configure GitHub OAuth app in admin → Profile → Connect GitHub → complete device flow
  • Manually: agent session emitting gh issue list uses injected GH_TOKEN

🤖 Generated with Claude Code

vaayne added 6 commits April 21, 2026 23:38
Implements internal/oauthcli/ — host-side OAuth plumbing for GitHub and
Lark/Feishu. Includes a mutex-protected in-memory FlowStore, JSON vault
serialisation helpers, GitHubBroker (full RFC 8628 device flow with
slow_down/expired handling), LarkBroker (authorisation-code flow adapted for
device-like use with app-access-token exchange), and TokenManager with
automatic Lark access-token refresh.  LarkOAuthBundle stores AppSecret at
rest (already encrypted by the vault) so TokenManager is self-contained for
refresh without requiring the credentials at call time.

Assisted-by: claude-code:claude-sonnet-4-6
Adds device-flow OAuth connect/disconnect for GitHub and Lark/Feishu
on the user profile page, with in-memory broker caching and vault persistence.

Assisted-by: claude-code:claude-sonnet-4-6
- Add internal/cliwrap package with EnsureWrappers to write POSIX shell
  wrappers for gh and lark-cli under UserRoot/.anna/bin/
- Extend GoRunnerConfig with TokenManager, LarkAppID, LarkBrand fields
- buildSandboxEnv strips GH_OAUTH/LARK_CLI_OAUTH bundle keys from sandbox
  env and injects GH_TOKEN and Lark runtime vars via TokenManager
- Set ANNA_GH_BIN/ANNA_LARK_BIN from binaries.ToolPath so wrappers can
  locate real binaries without PATH ambiguity
- createLocalSession provisions wrappers and prepends wrapper dir to PATH
- createDockerSession provisions wrappers and sets ANNA_WRAPPER_DIR for
  Phase 5 PATH injection
- Wire TokenManager through PoolManager (auto-constructed from VaultStore
  when SetVaultEnvLoader is called) and NewRunnerFactory

Assisted-by: claude-code:claude-sonnet-4-6
… sandbox image

- Add injectWrapperPath helper: translates ANNA_WRAPPER_DIR to a container PATH
  prepend so CLI wrappers provisioned in UserRoot/.anna/bin/ are found by exec
- Add injectDockerBinPaths helper: overrides ANNA_GH_BIN/ANNA_LARK_BIN to
  container-side absolute paths (/usr/bin/gh, /usr/local/bin/lark-cli) so
  wrapper scripts never loop back through themselves
- Apply both helpers in dockerHost.Exec and dockerHost.StartProcess after
  translateEnvPaths
- Dockerfile: install gh 2.89.0 via official apt repo; install lark-cli 1.0.15
  from GitHub release tarball

Assisted-by: claude-code:claude-sonnet-4-6
Assisted-by: claude-code:claude-sonnet-4-6
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4247d14685

ℹ️ 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".

Comment thread internal/admin/oauth.go Outdated

s.oauthMu.Lock()
defer s.oauthMu.Unlock()
if s.larkBroker == nil || s.larkBrokerAppID != appID {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Rebuild Lark broker when auth config changes

getLarkBroker only invalidates the cached broker when app_id changes, so rotating app_secret or changing brand in the auth/lark plugin leaves the server using stale credentials. After such a config update, new OAuth exchanges and token refreshes continue to use the old secret/brand until process restart, which can break Lark login for all users. The cache key needs to include all fields used by oauthcli.LarkConfig (or skip caching here).

Useful? React with 👍 / 👎.

Comment thread internal/cliwrap/wrap.go
Comment on lines +15 to +19
ghWrapper = "#!/bin/sh\nexec \"${ANNA_GH_BIN:-gh}\" \"$@\"\n"

// larkWrapper is the shell script placed at binDir/lark-cli.
// It delegates to the real binary path exported by the runner as ANNA_LARK_BIN.
larkWrapper = "#!/bin/sh\nexec \"${ANNA_LARK_BIN:-lark-cli}\" \"$@\"\n"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove self-recursive fallback from CLI wrapper scripts

The wrappers execute "${ANNA_GH_BIN:-gh}" and "${ANNA_LARK_BIN:-lark-cli}"; when ANNA_*_BIN is unset (e.g., local backend with missing/unextracted embedded binaries), the fallback resolves the same wrapper name from the PATH-prepended wrapper directory and loops indefinitely. This causes gh/lark-cli calls to hang instead of failing fast or invoking a real binary. Use absolute resolved binaries before enabling wrappers, or fail explicitly when ANNA_*_BIN is absent.

Useful? React with 👍 / 👎.

vaayne and others added 15 commits April 22, 2026 08:54
Move gh and lark-cli installation from Dockerfile to mise-managed tools
in _mise.toml for better version flexibility and consistency.
… and Lark auth URL

Replace hand-rolled HTTP for GitHub device flow with oauth2.Config + github.Endpoint.
StartDeviceFlow spawns a background goroutine running cfg.DeviceAccessToken; Poll reads
FlowStore state without making HTTP calls. Lark auth URL now built via oauth2.Config.AuthCodeURL;
token exchange and refresh stay as custom HTTP (Lark requires Bearer app_access_token header).
Extract postJSON helper to http.go.

Assisted-by: claude-code:claude-sonnet-4-6
…et map, drop unused params

- vault.go: replace duplicate marshal/unmarshal logic with generic saveBundle/loadBundle helpers
- lark.go: remove larkFlowSecret struct and secret map (bundle field was unreachable in Poll)
- token_manager.go: drop unused appID/brand params from GetLarkRuntimeEnv
- sandbox_backend.go: update call site to match new signature

Assisted-by: Claude Code:claude-sonnet-4-6
Replace custom postJSON token exchange and refresh with golang.org/x/oauth2:
- Add larkTokenTransport: converts form-encoded→JSON requests, injects
  app_access_token as Bearer, and unwraps Lark's {"code":0,"data":{…}}
  envelope to flat OAuth2 JSON so the library can parse it normally.
- LarkBroker.Complete: uses cfg.Exchange() via larkOAuthContext.
- TokenManager.refreshLarkToken: uses cfg.TokenSource() pointed at Lark's
  dedicated refresh endpoint.
- Extract fetchLarkAppToken and larkBundleFromToken as shared helpers;
  remove larkUserTokenResponse and larkRefreshResponse hand-rolled types.
- larkBaseURL promoted to package-level (was LarkBroker method).

Assisted-by: Claude Code:claude-sonnet-4-6
…m transport

Lark's v2 token endpoint (/open-apis/authen/v2/oauth/token) follows standard
OAuth2: form-encoded requests with client_id/client_secret in body, flat JSON
responses. No app access token pre-fetch is required.

- Remove larkTokenTransport, larkOAuthContext, fetchLarkAppToken,
  larkAppTokenResponse — all workarounds for the old v1 OIDC endpoints.
- Delete http.go (postJSON helper, now unused).
- oauthConfig: add ClientSecret, point TokenURL at v2 endpoint.
- Complete: plain cfg.Exchange(ctx, code) — no custom HTTP client.
- refreshLarkToken: plain cfg.TokenSource(ctx, existing).Token() — no app
  token step, no custom transport.
- Update extra field key refresh_expires_in → refresh_token_expires_in (v2
  response field name).

Assisted-by: Claude Code:claude-sonnet-4-6
Add workflow (push Actions files), gist, and user scopes alongside the
existing repo and read:org. Matches the scope set used by the gh CLI.
Switch from a single comma-joined const to a proper []string var so
the oauth2 library sends each scope individually.

Assisted-by: Claude Code:claude-sonnet-4-6
…tion

lark-cli always appends offline_access to ensure the server issues a
refresh token. Without it the v2 endpoint may skip the refresh token,
breaking token renewal entirely.

Also switch from a single const string to a proper []string var
(matching the GitHub change) so the oauth2 library sends each scope
individually.

Assisted-by: Claude Code:claude-sonnet-4-6
Cover the common use cases: IM read/send, calendar, all document types
(docs, docx, wiki, sheets, slides), tasks, and file downloads.

Scope choices:
- im:message + im:chat:readonly  — user-level messaging (not bot)
- calendar:calendar:readonly     — read events/free-busy
- drive:file:download + drive:drive.metadata:readonly
- docs:document.content:read + comment + export  (legacy format)
- docx:document, wiki:wiki, sheets:spreadsheet  (combined read/write)
- slides:presentation:read + update
- task:task + task:comment read/write

Assisted-by: Claude Code:claude-sonnet-4-6
Both plugins were defined and imported but never inserted into
settings_plugins, causing "sql: no rows in result set" whenever
the admin OAuth routes called pluginHost.Config().Get().

- Add PluginKindAuth = "auth" constant
- Add builtinAuthNames = ["github", "lark"]
- Include auth plugins in BuiltinPluginIDs()
- Call seedBuiltinPlugins for auth kind in seedPlugins()

Auth plugins seed with enabled=0 (disabled by default, requiring
explicit configuration before activation).

Assisted-by: Claude Code:claude-sonnet-4-6
- Add optional redirect_url field to auth/github and auth/lark plugin
  configs; falls back to {server}/api/auth/profile/oauth/{provider}/callback
- Add WithRedirectURI to GitHubBroker (matching LarkBroker)
- Invalidate cached brokers when redirect_url changes
- Store UserID in FlowStatus at flow-start time
- Exempt Lark callback from authMiddleware; resolve user via flow store
  state param so the redirect works without a session cookie

Assisted-by: claude-sonnet-4-6
- Replace file tab bar with dropdown selector in skills drawer
- Make custom agent skill rows clickable to open skills drawer
- Switch builtin skill badges to open drawer (read-only) instead of toggle
- Add openAgentSkill() for agent-scoped drawer navigation
- Update Lark builddeps and sync_builtin for device-flow OAuth
- Update docs for Feishu channel and agent templates

Assisted-by: claude-code:claude-sonnet-4-6
Sessions are now accessible via /sessions/<session_id>, enabling
shareable deep links. Navigation between list and detail views
updates the browser URL via history.pushState.

Assisted-by: claude-code:claude-sonnet-4-6
- Drop Seatbelt sandbox-exec from macOS local backend; commands now run
  directly on the host OS. Update docs and tests accordingly.
- Update cli-oauth docs with redirect_url config notes.
- Refactor lark.go and oauthcli token_manager; add token_manager tests.

Assisted-by: claude-code:claude-sonnet-4-6
…i coverage

- Add comprehensive tests for plugins/auth/github (94.7% coverage)
- Add comprehensive tests for plugins/auth/lark (95.7% coverage)
- Expand token_manager tests in internal/oauthcli for GetGHToken and error cases

This raises total coverage from 49.5% to 50.1%, meeting the 50% threshold.

Assisted-by: pi:gpt-5.4
@vaayne vaayne merged commit 18e4429 into main Apr 22, 2026
6 of 7 checks passed
@vaayne vaayne deleted the feat/device-flow-oauth branch April 24, 2026 05:49
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.

1 participant