Files
tuxedomm fbed6beac3 refactor: split Execute into Build + Execute with explicit IO and keychain injection (#371)
* refactor(cmd): split Execute into Build with IO/Keychain injection

Introduce a public cmd.Build entry point so external consumers (cli-server,
MCP server, other embedders) can assemble the full CLI command tree without
going through os.Args or the platform keychain. Build takes an
InvocationContext plus functional BuildOptions:

  * WithIO(in, out, errOut) — inject custom streams; terminal detection
    is derived from the input's underlying *os.File when present.
  * WithKeychain(kc)        — swap the credential store.
  * HideProfile(bool)       — registered later in cmd.HideProfile.

The existing Execute() keeps using the internal buildInternal (which
still returns the Factory so error handling can attribute exit codes),
and SetDefaultFS replaces the global VFS implementation at startup.

Hardening applied up front:

  * cmdutil.NewIOStreams(in, out, errOut) centralizes terminal detection
    so SystemIO() and WithIO share one path.
  * cmdutil.NewDefault normalizes partial IOStreams — callers may pass
    &IOStreams{Out: buf} without tripping nil-writer panics in the
    RoundTripper warnings, Cobra, or the credential provider.
  * Build guards against nil functional options.
  * An API contract test (cmd/build_api_test.go) exercises Build +
    WithIO + WithKeychain + HideProfile + SetDefaultFS so the public
    surface is reachable by deadcode analysis.

Change-Id: I7c895e6019817401accbde2db3ef800da40ad319

* feat(schema): filter methods by strict mode in schema output

When strict mode is active, schema output now excludes methods that
are incompatible with the forced identity. This applies to both
pretty and JSON output formats at the resource and method levels.

Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7

* refactor: centralize strict-mode as flag registration

Change-Id: Iec11151c5002c2f58a8aa067d08747db2e4d2d8c

* fix(cmd): align strict-mode completion and build context; drop dead register shims

Thread a context.Context through RegisterShortcuts, RegisterServiceCommands,
and service.registerService/Resource/Method by introducing explicit
*WithContext variants. Pass that context into NewCmdServiceMethodWithContext
so shortcut and service command construction can honor cancellation and
strict-mode pruning consistently.

Also drop the context-less registerMethod and registerResource shims —
they became unreachable once the WithContext variants took over, and
were the source of new deadcode warnings. registerService is retained
because service_test.go still calls it directly.

Change-Id: I3fe5673aed663c7383bbbc5b0ae94d1f3491f22d

* refactor(cmd): hide --profile in single-app mode via build option

- GlobalOptions gains HideProfile; RegisterGlobalFlags stays pure and reads
  the policy off the struct. No boolean-trap parameter, one call per site.
- buildConfig holds GlobalOptions inline so HideProfile(bool) BuildOption
  mutates it directly. buildInternal stays a pure assembly function and
  requires callers to supply WithIO — no implicit os.Std* fallback.
- Add WithIO BuildOption (wrapping raw io.Reader/Writer with automatic
  *os.File TTY detection); Execute injects streams explicitly and decides
  profile visibility via HideProfile(isSingleAppMode()).
- installTipsHelpFunc force-shows hidden root flags while rendering the
  root command's own help, so single-app users still discover --profile
  via lark-cli --help without it polluting subcommand helps.

Change-Id: I7755387e993992ca969e0a4a6f54441cc1993eef

* feat(transport): extension abort hook and shared base transport

Two transport-layer changes bundled because both reshape the base
round-tripper contract used by the HTTP client, the Lark SDK client,
and the in-process updater.

1. Extension abort hook (PreRoundTripE).

   Extensions implementing exttransport.AbortableInterceptor can now
   return an error from PreRoundTripE to skip the built-in chain. The
   post hook still fires with (nil, reason) so extensions can unwind
   resources. extensionMiddleware captures the provider name so the
   returned *AbortError carries attribution.

2. Shared base transport to stop RPC leak.

   util.NewBaseTransport cloned http.DefaultTransport on every call, so
   each cmdutil.Factory produced a fresh *http.Transport whose
   persistConn readLoop/writeLoop goroutines lingered until
   IdleConnTimeout (~90s). Invisible in a single-process CLI, but the
   fork is consumed by cli-server where each RPC request constructs a
   new Factory, causing linear memory + goroutine growth under load.

   Replace NewBaseTransport with SharedTransport — returns
   http.DefaultTransport (the stdlib-wide singleton) by default, and
   a cached proxy-disabled clone only when LARK_CLI_NO_PROXY is set.
   Return type is http.RoundTripper to discourage in-place mutation of
   the shared instance. FallbackTransport is kept as a thin
   *http.Transport wrapper so existing callers in internal/auth and
   internal/cmdutil transport decorators (which were already on the
   singleton path) do not have to migrate.

   Leak-site migrations: factory_default.go (HTTP + SDK base) and
   update.go now call SharedTransport directly.

Change-Id: Ia82462134c5c5ee838be878b887860f41446a235

* fix: unblock Build() zero-opts path and sidecar demo build

Two regressions surfaced on refactor/build-execute-split:

1. cmd.Build(ctx, inv) without WithIO panicked at rootCmd.SetIn/Out/Err
   because cfg.streams stayed nil — NewDefault normalized internally
   but cmd/build.go never saw the normalized value. Default cfg.streams
   to cmdutil.SystemIO() before the root command wires them, and add a
   TestBuild_NoOptions regression guard.

2. sidecar/server-demo/main.go still called cmdutil.NewDefault(inv),
   so `go build -tags authsidecar_demo ./sidecar/server-demo` failed
   with "not enough arguments". Pass nil for the new streams parameter
   to preserve the prior behavior (NewDefault substitutes SystemIO).

Change-Id: I20227b2355cde7d19e22eba3eb841c6d8611e8a7
2026-04-21 14:48:40 +08:00
..
2026-04-20 20:24:51 +08:00
2026-04-20 20:24:51 +08:00
2026-04-20 20:24:51 +08:00
2026-04-20 20:24:51 +08:00
2026-04-20 20:24:51 +08:00
2026-04-20 20:24:51 +08:00

Sidecar Server Reference Implementation

⚠️ This is a demo. For production deployment, implement your own sidecar server conforming to the wire protocol in github.com/larksuite/cli/sidecar.

This example shows how to implement a sidecar auth proxy server that receives HMAC-signed requests from lark-cli sandbox clients and forwards them to the Lark/Feishu API with real credentials injected.

What this demo shows

  • HMAC-SHA256 request verification (timestamp drift, body digest, signature)
  • Target host allowlist + https-only target validation (anti-SSRF / anti-downgrade)
  • Identity-based token resolution (UAT for user, TAT for bot)
  • Auth-header allowlist: real token may only be injected into Authorization / X-Lark-MCP-UAT / X-Lark-MCP-TAT, rejecting attempts to smuggle it into Cookie, User-Agent, or other intermediate-logged headers
  • Audit logging with path ID-segment sanitization and upstream error truncation
  • Safe request forwarding (strips client-supplied auth headers)

What this demo does NOT handle

  • TAT refresh — the shared DefaultTokenProvider caches the TAT via sync.Once, which never refreshes. A long-running server will return an expired TAT after 2 hours. Production implementations should maintain a TTL-based cache with early renewal.
  • High availability / load balancing / hot key rotation
  • TLS termination
  • Rate limiting / per-identity quotas

Both sides need the right build tags

Sidecar is split into two separate binaries with different build tags:

Side Binary Build tag How to build
Sandbox (client) lark-cli authsidecar go build -tags authsidecar -o lark-cli .
Trusted (server) sidecar-server-demo authsidecar_demo go build -tags authsidecar_demo -o sidecar-server-demo ./sidecar/server-demo/

If the sandbox runs a standard lark-cli without -tags authsidecar, the LARKSUITE_CLI_AUTH_PROXY env var is ignored and requests bypass the sidecar entirely — real credentials (if any) leak to the sandbox.

Prerequisites

The demo reuses the lark-cli credential pipeline, so the trusted machine must have an app configured:

lark-cli config init --new   # configure app_id / app_secret (required)
lark-cli auth login          # store user refresh_token in keychain
                              # (only required if sandbox will use --as user)

auth login is only required for user identity. If the server will only serve bot requests (TAT), config init alone is enough because the TAT is minted from app_id + app_secret.

Also, the server process must not inherit LARKSUITE_CLI_AUTH_PROXY — if it does, the sidecar credential provider would activate inside the server and return sentinel tokens instead of real ones. The demo rejects this at startup with a clear error, but you should make sure to unset LARKSUITE_CLI_AUTH_PROXY in the server shell before launching.

Run

./sidecar-server-demo \
  --listen 127.0.0.1:16384 \
  --key-file <HOME>/.lark-sidecar/proxy.key \
  --log-file <HOME>/.lark-sidecar/audit.log

Flags

Flag Default Purpose
--listen 127.0.0.1:16384 Address to bind the HTTP listener
--key-file <HOME>/.lark-sidecar/proxy.key Path to write the generated HMAC key (mode 0600)
--log-file (empty, stderr) Audit log output path
--profile (empty, active profile) lark-cli profile name for credential lookup

Startup output

Auth sidecar listening on http://127.0.0.1:16384
HMAC key prefix: a3b2c1d4
Full key written to /Users/alice/.lark-sidecar/proxy.key (mode 0600)

Set in sandbox:
  export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
  export LARKSUITE_CLI_PROXY_KEY="<read from /Users/alice/.lark-sidecar/proxy.key>"
  export LARKSUITE_CLI_APP_ID="cli_xxx"
  export LARKSUITE_CLI_BRAND="feishu"

The key-file path is printed exactly as passed on the command line (relative paths stay relative). The HMAC key prefix is the first 8 characters for identification without revealing the full key.

Sandbox env vars (complete list)

The startup banner only prints the required variables. Two more are optional:

export LARKSUITE_CLI_AUTH_PROXY="http://..."       # required (see constraints below)
export LARKSUITE_CLI_PROXY_KEY="..."               # required
export LARKSUITE_CLI_APP_ID="cli_xxx"              # required
export LARKSUITE_CLI_BRAND="feishu"                # required (feishu | lark)
export LARKSUITE_CLI_DEFAULT_AS="user"             # optional: force default identity
export LARKSUITE_CLI_STRICT_MODE="user"            # optional: lock sandbox to one identity

LARKSUITE_CLI_AUTH_PROXY constraints — validated by the CLI on startup:

  • Scheme must be http:// (or bare host:port). https:// is rejected today because the interceptor does not yet perform TLS; a future PR that wires up real TLS will relax this.
  • Host must be loopback (127.0.0.1, ::1) or one of the recognized same-host aliases: localhost, host.docker.internal, host.containers.internal, host.lima.internal, gateway.docker.internal. The sidecar pattern is inherently same-machine; cross-machine deployment is a different product (auth broker / STS) with different security requirements (mTLS, cert rotation, per-client keys) and is not supported by this feature.
  • No path, query, fragment, or user:pass@ in the URL.

How auto identity detection works in sidecar mode: on every invocation the CLI asks the sidecar to look up the logged-in user's open_id via /open-apis/authen/v1/user_info. If that succeeds, --as defaults to user; if it fails (trusted side has no valid user login, or the call errors out), it falls back to bot. Setting LARKSUITE_CLI_DEFAULT_AS=user lets you short-circuit this and always default to user regardless of the lookup result; set it to bot for the opposite.

Note: LARKSUITE_CLI_STRICT_MODE and the server's identity allowlist are two separate enforcement points:

  • STRICT_MODE is interpreted locally by the sandbox CLI — it rejects --as values the sandbox itself disallows, before any request goes out.
  • The server's allowlist is built from the trusted-side config's SupportedIdentities (sidecar/server-demo/allowlist.go). The sandbox cannot override it.

A well-configured deployment aligns both (e.g. both set to user when the app only supports user tokens), but they are computed independently.

Graceful shutdown

Send SIGINT (Ctrl+C) or SIGTERM to stop the server. The demo drains in-flight requests with a 5-second timeout before exiting.

Wire protocol

See the sidecar package on pkg.go.dev for protocol constants, HMAC signing/verification, and address validation utilities.

Headers (client → server):

Header Purpose
X-Lark-Proxy-Version Wire-protocol version (currently "v1"). Server rejects unknown values with 400.
X-Lark-Proxy-Target Original target scheme + host only (e.g. https://open.feishu.cn). Must be https://; any path/query/fragment/userinfo in this header is rejected. The path and query come from the request line itself; the server reconstructs the upstream URL as https://<host> + requestURI.
X-Lark-Proxy-Identity "user" or "bot". Covered by the signature.
X-Lark-Proxy-Auth-Header Which header the server should inject real token into. Covered by the signature.
X-Lark-Proxy-Signature hex-encoded HMAC-SHA256
X-Lark-Proxy-Timestamp Unix seconds (drift ≤ 60s)
X-Lark-Body-SHA256 hex-encoded SHA-256 of the request body

Signing material (newline-separated, in order):

version
method
host
pathAndQuery
bodySHA256
timestamp
identity
authHeader

Every field above is part of the canonical string. In particular, identity and authHeader are covered so a captured request cannot be replayed with its identity flipped (bot↔user) or its auth-header redirected (e.g. into Cookie) inside the 60s drift window.

Source layout

File Purpose
main.go Entry point: flag parsing, server lifecycle
handler.go proxyHandler.ServeHTTP — main request flow
forward.go Forwarding HTTP client + proxy-header filter
allowlist.go Target host / identity allowlists
audit.go Log path/error sanitization
handler_test.go Unit tests for all of the above