✨ feat: device-flow OAuth for gh and lark-cli#121
Conversation
Assisted-by: claude-code:claude-sonnet-4-6
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
There was a problem hiding this comment.
💡 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".
|
|
||
| s.oauthMu.Lock() | ||
| defer s.oauthMu.Unlock() | ||
| if s.larkBroker == nil || s.larkBrokerAppID != appID { |
There was a problem hiding this comment.
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 👍 / 👎.
| 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" |
There was a problem hiding this comment.
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 👍 / 👎.
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
Summary
GH_TOKEN/LARKSUITE_CLI_*env vars into sandbox sessions at startup so rawghandlark-clishell commands from agents work transparently~/.anna/bin/gh,~/.anna/bin/lark-cli) prepended to PATH; Docker sessions get container-translated wrapper PATH + fixedANNA_GH_BIN/ANNA_LARK_BINabsolute paths to avoid exec loopsgh 2.89.0andlark-cli 1.0.15to the sandbox Docker imagePhases
plugins/auth/github+plugins/auth/lark— admin config plugins for OAuth app credentialsinternal/oauthcli/— device-flow broker, token manager, vault bundle serializationinternal/admin/oauth.go— profile API routes + UI section on the Profile pageinternal/cliwrap/— wrapper provisioning; runner env injection + bundle key filteringPATHinjection viaANNA_WRAPPER_DIR; gh + lark-cli inDockerfileKnown limitations
Test plan
mise run test— all tests pass (pre-existing Docker container conflict ininternal/sandboxis unrelated)mise run build— builds cleangh issue listuses injectedGH_TOKEN🤖 Generated with Claude Code