Compare commits

..

1 Commits

Author SHA1 Message Date
zhaoyukun.yk
3bb68c317a feat(auth): support managed external credentials
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities.

Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication.

Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
2026-07-30 18:33:25 +08:00
323 changed files with 15080 additions and 15936 deletions

View File

@@ -82,6 +82,56 @@ jobs:
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
extended-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build both editions and verify identity
run: |
set -euo pipefail
go build -o /tmp/lark-cli-standard .
go build -tags extended -o /tmp/lark-cli-extended .
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
- name: Cross-compile Extended platform-specific security code
run: |
set -euo pipefail
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
- name: Verify edition source isolation
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
- name: Run Extended tests
run: make extended-test
extended-platform-security:
needs: fast-gate
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run native helper isolation and path trust tests
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -142,6 +192,28 @@ jobs:
node-version: '22'
- name: Run script tests
run: make script-test
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
install-only: true
- name: Validate GoReleaser configuration
run: goreleaser check
- name: Check Extended installer syntax
shell: pwsh
run: |
sh -n scripts/install-extended.sh
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path scripts/install-extended.ps1),
[ref]$tokens,
[ref]$errors
) | Out-Null
if ($errors.Count -ne 0) {
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
deterministic-gate:
needs: fast-gate
@@ -216,16 +288,20 @@ jobs:
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
# Extended implementation files are selected by build tags and would
# otherwise be absent from the uploaded report. Their race-enabled
# suite runs in extended-integration; this pass contributes coverage.
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
files: coverage-standard.txt,coverage-extended.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
@@ -235,21 +311,31 @@ jobs:
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
report_coverage() {
profile="$1"
label="$2"
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -f "$profile" ]; then
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
return
fi
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func="$profile" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
}
report_coverage coverage-standard.txt Standard
report_coverage coverage-extended.txt Extended
deadcode:
needs: fast-gate
@@ -520,7 +606,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -542,6 +628,8 @@ jobs:
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
@@ -565,7 +653,9 @@ jobs:
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
"${{ needs.license-header.result }}" \
"${{ needs.extended-integration.result }}" \
"${{ needs.extended-platform-security.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi

View File

@@ -46,6 +46,8 @@ jobs:
runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -68,7 +70,7 @@ jobs:
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
- name: Build and upload draft release with GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
@@ -80,14 +82,41 @@ jobs:
run: |
set -euo pipefail
test -s dist/checksums.txt
cp scripts/install-extended.sh scripts/install-extended.ps1 dist/
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Verify release edition identities
run: |
set -euo pipefail
mkdir -p /tmp/lark-cli-standard /tmp/lark-cli-extended
tar -xzf "dist/lark-cli-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-standard lark-cli
tar -xzf "dist/lark-cli-extended-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-extended lark-cli
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
- name: Verify release platform asset matrix
run: bash scripts/verify-release-assets.sh dist "${GITHUB_REF_NAME#v}"
- name: Attest release archives
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/install-extended.sh
dist/install-extended.ps1
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
cp dist/*.tar.gz dist/*.zip dist/checksums.txt \
dist/install-extended.sh dist/install-extended.ps1 \
npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -97,6 +126,76 @@ jobs:
if-no-files-found: error
overwrite: true
- name: Publish verified GitHub release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
RELEASE_TAG: ${{ github.ref_name }}
with:
github-token: ${{ github.token }}
script: |
const crypto = require("node:crypto");
const fs = require("node:fs");
const tag = process.env.RELEASE_TAG;
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const matches = releases.filter((release) => release.tag_name === tag);
if (matches.length !== 1) {
throw new Error(`expected exactly one draft release for ${tag}, found ${matches.length}`);
}
const release = matches[0];
if (!release.draft) {
throw new Error(`release ${tag} became public before verification completed`);
}
const checksumPath = "dist/checksums.txt";
const checksumBody = fs.readFileSync(checksumPath, "utf8");
const expectedDigests = new Map();
for (const line of checksumBody.split(/\r?\n/)) {
if (!line.trim()) continue;
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (!match) throw new Error(`invalid checksums.txt line: ${line}`);
const name = match[2];
if (expectedDigests.has(name)) {
throw new Error(`duplicate checksums.txt entry: ${name}`);
}
expectedDigests.set(name, `sha256:${match[1].toLowerCase()}`);
}
expectedDigests.set(
"checksums.txt",
`sha256:${crypto.createHash("sha256").update(checksumBody).digest("hex")}`,
);
const actualNames = release.assets.map((asset) => asset.name).sort();
const expectedNames = [...expectedDigests.keys()].sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error(
`draft release asset set mismatch: expected ${expectedNames.join(", ")}, got ${actualNames.join(", ")}`,
);
}
for (const asset of release.assets) {
const expected = expectedDigests.get(asset.name);
if (!asset.digest) {
throw new Error(`GitHub did not report a digest for draft asset ${asset.name}`);
}
if (asset.digest.toLowerCase() !== expected) {
throw new Error(
`draft asset digest mismatch for ${asset.name}: expected ${expected}, got ${asset.digest}`,
);
}
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: false,
make_latest: "true",
});
publish-npm:
needs: build-release
runs-on: ubuntu-22.04

View File

@@ -5,7 +5,8 @@ before:
- python3 scripts/fetch_meta.py
builds:
- binary: lark-cli
- id: standard
binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
@@ -18,12 +19,54 @@ builds:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
- id: extended
binary: lark-cli
tags:
- extended
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
- id: standard
ids:
- standard
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats:
- zip
files:
- README.md
- LICENSE
- CHANGELOG.md
- id: extended
ids:
- extended
name_template: "lark-cli-extended-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- LICENSE
@@ -31,6 +74,18 @@ archives:
checksum:
name_template: checksums.txt
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
release:
# Keep assets undiscoverable by releases/latest until the workflow has
# independently verified checksums, edition identity, and platform coverage.
draft: true
replace_existing_draft: true
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
changelog:
sort: asc

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test extended-test
all: test
@@ -50,6 +50,7 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/release-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
@@ -121,6 +122,12 @@ sidecar-test:
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# extended-test compiles and exercises the separately distributed Extended
# edition. The default build remains the Standard npm/npx binary.
extended-test:
go build -tags extended -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags extended ./cmd/... ./internal/... ./shortcuts/... ./extension/... ./tests/externalcredential_e2e
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -30,21 +31,23 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
login := NewCmdAuthLogin(f, nil)
logout := NewCmdAuthLogout(f, nil)
status := NewCmdAuthStatus(f, nil)
scopes := NewCmdAuthScopes(f, nil)
list := NewCmdAuthList(f, nil)
check := NewCmdAuthCheck(f, nil)
qrcode := NewCmdAuthQRCode(f, nil)
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
cmdutil.SetRuntimeCapabilities(diagnostic)
}
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
return cmd
}

View File

@@ -530,10 +530,7 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
@@ -558,3 +555,19 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
})
}
}
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
t.Run(name, func(t *testing.T) {
cmd := NewCmdAuth(f)
matched, _, err := cmd.Find([]string{name})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("read-only command blocked: %v", err)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"context"
"fmt"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
@@ -33,7 +35,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
if runF != nil {
return runF(opts)
}
return authCheckRun(opts)
return authCheckRunContext(cmd.Context(), opts)
},
}
@@ -46,6 +48,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
func authCheckRun(opts *CheckOptions) error {
return authCheckRunContext(context.Background(), opts)
}
func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
@@ -57,18 +63,74 @@ func authCheckRun(opts *CheckOptions) error {
if err != nil {
return err
}
if config.UserOpenId == "" {
if f.Credential == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "credential inspection is unavailable")
}
inspection, err := f.Credential.InspectToken(ctx, credential.TokenInspectionRequest{
TokenSpec: credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: config.AppID,
},
IncludeScopes: true,
})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"failed to inspect user authorization: %v", err).
WithCause(err)
}
if inspection == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source returned no authorization inspection")
}
if inspection.Status == credential.TokenInspectionNotLoggedIn && !inspection.Source.Managed {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
return output.ErrBare(1)
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
if !inspection.Present {
if inspection.Source.Managed {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"credential source %q did not provide a user access token", inspection.Source.Name).
WithHint("authorize the user through the selected credential source")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
return output.ErrBare(1)
}
missing := larkauth.MissingScopes(stored.Scope, required)
switch inspection.ScopeState {
case credential.ScopeUnsupported:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check is unsupported by credential source %q because granted scopes are unavailable", inspection.Source.Name).
WithHint("the credential source must expose trusted scope metadata before `auth check` can evaluate --scope")
case credential.ScopeUnknown:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check result is unknown because credential source %q returned no scope metadata", inspection.Source.Name).
WithHint("configure the credential source to return trusted scopes for user access tokens")
case credential.ScopeKnown:
// Continue below.
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source %q returned invalid scope inspection state %q", inspection.Source.Name, inspection.ScopeState)
}
suggestion := ""
missing := larkauth.MissingScopes(inspection.Scopes, required)
if inspection.Source.Managed {
if len(missing) > 0 {
suggestion = fmt.Sprintf("grant these scopes through credential source %s: %s", inspection.Source.Name, strings.Join(missing, " "))
}
} else {
suggestion = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
return writeAuthCheckResult(f, required, inspection.Scopes, suggestion)
}
func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes, suggestion string) error {
missing := larkauth.MissingScopes(availableScopes, required)
missingSet := make(map[string]bool, len(missing))
for _, s := range missing {
missingSet[s] = true
@@ -82,8 +144,8 @@ func authCheckRun(opts *CheckOptions) error {
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
if len(missing) > 0 && suggestion != "" {
result["suggestion"] = suggestion
}
output.PrintJson(f.IOStreams.Out, result)
if !ok {

View File

@@ -4,14 +4,20 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -146,6 +152,128 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
}
}
type authCheckExternalProvider struct {
token *extcred.Token
capabilities credential.ProviderCapabilities
resolveCalls int
}
func (p *authCheckExternalProvider) Name() string { return "external-check-test" }
func (p *authCheckExternalProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app", Brand: extcred.BrandFeishu}, nil
}
func (p *authCheckExternalProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
p.resolveCalls++
return p.token, nil
}
func (p *authCheckExternalProvider) CredentialCapabilities() credential.ProviderCapabilities {
return p.capabilities
}
func externalAuthCheckFactory(t *testing.T, canInspectScopes bool, token *extcred.Token) (*cmdutil.Factory, *authCheckExternalProvider) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app",
Brand: core.BrandFeishu,
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
provider := &authCheckExternalProvider{
token: token,
capabilities: credential.ProviderCapabilities{
ProvidesOnDemandAuth: true,
CanInspectScopes: canInspectScopes,
},
}
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
nil,
)
return f, provider
}
func TestAuthCheckRun_ExternalDirectUsesProviderScopes(t *testing.T) {
f, provider := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
Scopes: "im:message docx:document",
})
stdout := f.IOStreams.Out.(*bytes.Buffer)
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
if err != nil {
t.Fatalf("authCheckRun() error = %v", err)
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveToken calls = %d, want 1", provider.resolveCalls)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Fatalf("stdout.ok = %v, want true; payload=%v", payload["ok"], payload)
}
granted, ok := payload["granted"].([]any)
if !ok || len(granted) != 1 || granted[0] != "im:message" {
t.Fatalf("stdout.granted = %v, want [im:message]", payload["granted"])
}
}
func TestAuthCheckRun_ExternalProxyReturnsTypedUnknown(t *testing.T) {
f, provider := externalAuthCheckFactory(t, false, &extcred.Token{
Value: "proxy-placeholder",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unsupported") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unsupported result with actionable hint and no param", problem)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveToken calls = %d, want 0 for proxy scope check", provider.resolveCalls)
}
}
func TestAuthCheckRun_ExternalDirectWithoutScopeMetadataReturnsTypedUnknown(t *testing.T) {
f, _ := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unknown") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unknown result with actionable hint and no param", problem)
}
}
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal

View File

@@ -56,6 +56,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -44,6 +44,10 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
func authStatusRun(opts *StatusOptions) error {
f := opts.Factory
editionStatus, err := inspectEditionStatus(f)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
@@ -64,7 +68,9 @@ func authStatusRun(opts *StatusOptions) error {
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
if !applyEditionStatus(result, diagnostics, editionStatus) {
addStatusNote(result, diagnostics)
}
output.PrintJson(f.IOStreams.Out, result)
return nil

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"context"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct {
provider string
variant string
}
func inspectEditionStatus(f *cmdutil.Factory) (editionStatusState, error) {
if f == nil || f.Credential == nil {
return editionStatusState{}, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return editionStatusState{}, err
}
if source == nil || !source.Managed {
return editionStatusState{}, nil
}
state := editionStatusState{provider: source.Name}
description := f.RuntimeDescription()
if description.Managed {
state.variant = description.Variant
}
return state, nil
}
func applyEditionStatus(result map[string]interface{}, diagnostics identitydiag.Result, state editionStatusState) bool {
if state.provider == "" {
return false
}
result["source"] = "external"
result["credentialProvider"] = state.provider
if state.variant != "" {
result["externalCredentialMode"] = state.variant
}
switch {
case !diagnostics.User.Available && diagnostics.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(diagnostics.User.Status) +
"; bot identity is ready. Update authorization through external credential provider " + state.provider + "."
case diagnostics.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh. Check external credential provider " + state.provider + "."
case !diagnostics.User.Available && !diagnostics.Bot.Available:
result["note"] = "No usable identity is available. Check external credential provider " + state.provider + "."
}
return true
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedAuthStatusReportsManagedSource(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
},
}))
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["source"] != "external" ||
got["credentialProvider"] != "env" ||
got["externalCredentialMode"] != "managed-test" ||
got["identity"] != "bot" {
t.Fatalf("output = %#v", got)
}
if note, _ := got["note"].(string); strings.Contains(note, "auth login") ||
!strings.Contains(note, "external credential provider env") {
t.Fatalf("note = %q", note)
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct{}
func inspectEditionStatus(*cmdutil.Factory) (editionStatusState, error) {
return editionStatusState{}, nil
}
func applyEditionStatus(map[string]interface{}, identitydiag.Result, editionStatusState) bool {
return false
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardAuthStatusPreservesExistingProjection(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := got[field]; exists {
t.Fatalf("Standard auth status contains edition field %q: %s", field, stdout.String())
}
}
var note string
if err := json.Unmarshal(got["note"], &note); err != nil {
t.Fatal(err)
}
if !strings.Contains(note, "lark-cli auth login") {
t.Fatalf("Standard note = %q, want established login guidance", note)
}
}

View File

@@ -35,6 +35,15 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
t.Fatalf("json.Unmarshal(raw) error = %v", err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := raw[field]; exists {
t.Fatalf("local auth status unexpectedly contains edition field %q: %s", field, stdout.String())
}
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {

View File

@@ -29,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -45,6 +46,7 @@ type buildConfig struct {
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
runtime *runtimebootstrap.Result
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -58,6 +60,14 @@ func WithStartupBrand(brand core.LarkBrand) BuildOption {
}
}
// withRuntimeBootstrap shares one invocation snapshot across registry,
// credentials, transports, and command capabilities.
func withRuntimeBootstrap(runtime *runtimebootstrap.Result) BuildOption {
return func(c *buildConfig) {
c.runtime = runtime
}
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
@@ -143,9 +153,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
// buildInternal assembles the command tree from one immutable startup
// configuration snapshot. Profile selection happens before any registry
// network decision and the same result is passed to the Factory.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
@@ -168,13 +178,29 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
startup := cfg.runtime
if startup == nil {
startup = runtimebootstrap.Resolve(inv.Profile)
}
f := cmdutil.NewDefault(cfg.streams, inv)
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
// Runtime policy can close direct metadata egress before any command is
// registered, without exposing a concrete credential mode here.
registryBrand := cfg.startupBrand
if registryBrand == "" {
registryBrand = resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
}
if !startup.Plan.AllowsRemoteMetadata() {
if registryBrand == "" {
registryBrand = core.BrandFeishu
}
registry.InitEmbeddedWithBrand(registryBrand)
} else if registryBrand != "" {
registry.InitWithBrand(registryBrand)
}
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
@@ -220,6 +246,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
registerEditionCommands(rootCmd, f)
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
if !cfg.skipService {

176
cmd/build_workspace_test.go Normal file
View File

@@ -0,0 +1,176 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
func TestStartupProfileSnapshotUsesDetectedWorkspace(t *testing.T) {
previousWorkspace := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
tests := []struct {
name string
workspace core.Workspace
signalName string
signalValue string
expectedAppID string
expectedBrand core.LarkBrand
}{
{
name: "local",
workspace: core.WorkspaceLocal,
expectedAppID: "cli_local",
expectedBrand: core.BrandFeishu,
},
{
name: "openclaw",
workspace: core.WorkspaceOpenClaw,
signalName: "OPENCLAW_CLI",
signalValue: "1",
expectedAppID: "cli_openclaw",
expectedBrand: core.BrandLark,
},
{
name: "hermes",
workspace: core.WorkspaceHermes,
signalName: "HERMES_HOME",
signalValue: "/managed/hermes",
expectedAppID: "cli_hermes",
expectedBrand: core.BrandLark,
},
{
name: "lark_channel",
workspace: core.WorkspaceLarkChannel,
signalName: "LARK_CHANNEL",
signalValue: "1",
expectedAppID: "cli_lark_channel",
expectedBrand: core.BrandLark,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configRoot := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configRoot)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configRoot, "missing-external-credential.json"))
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
if tt.signalName != "" {
t.Setenv(tt.signalName, tt.signalValue)
}
writeWorkspaceProfile(t, core.WorkspaceLocal, "local", "cli_local", core.BrandFeishu)
if !tt.workspace.IsLocal() {
writeWorkspaceProfile(t, tt.workspace, tt.name, tt.expectedAppID, tt.expectedBrand)
}
// Execute resolves the registry brand before entering
// buildInternal. Pin that ordering independently.
core.SetCurrentWorkspace(core.WorkspaceLocal)
if got := selectInvocationWorkspace(); got != tt.workspace {
t.Fatalf("selected workspace = %q, want %q", got, tt.workspace)
}
if got := ResolveStartupBrand(""); got != tt.expectedBrand {
t.Fatalf("startup brand = %q, want %q", got, tt.expectedBrand)
}
// Build/buildInternal is also a public construction path. Reset the
// process state to local so the test proves it establishes the
// workspace before SelectProfile captures the immutable snapshot.
core.SetCurrentWorkspace(core.WorkspaceLocal)
factory, _, _ := buildInternal(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), io.Discard, io.Discard),
WithoutPlugins(),
WithoutServiceCommands(),
)
if got := core.CurrentWorkspace(); got != tt.workspace {
t.Fatalf("workspace after build = %q, want %q", got, tt.workspace)
}
config, err := factory.Config()
if err != nil {
t.Fatalf("Factory.Config() error = %v", err)
}
if config.AppID != tt.expectedAppID || config.Brand != tt.expectedBrand {
t.Fatalf("resolved config = app %q (%s), want app %q (%s)",
config.AppID, config.Brand, tt.expectedAppID, tt.expectedBrand)
}
})
}
}
func clearWorkspaceSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
"OPENCLAW_CLI",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_VERSION",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_SHELL",
"HERMES_HOME",
"HERMES_QUIET",
"HERMES_EXEC_ASK",
"HERMES_GATEWAY_TOKEN",
"HERMES_SESSION_KEY",
"LARK_CHANNEL",
} {
t.Setenv(name, "")
}
}
func clearCredentialSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
envvars.CliAppID,
envvars.CliAppSecret,
envvars.CliBrand,
envvars.CliUserAccessToken,
envvars.CliTenantAccessToken,
envvars.CliDefaultAs,
envvars.CliStrictMode,
} {
t.Setenv(name, "")
}
}
func writeWorkspaceProfile(
t *testing.T,
workspace core.Workspace,
name string,
appID string,
brand core.LarkBrand,
) {
t.Helper()
previous := core.CurrentWorkspace()
core.SetCurrentWorkspace(workspace)
defer core.SetCurrentWorkspace(previous)
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: name,
Apps: []core.AppConfig{{
Name: name,
AppId: appID,
AppSecret: core.PlainSecret("test-secret-" + name),
Brand: brand,
Users: []core.AppUser{},
}},
}); err != nil {
t.Fatalf("save %s workspace profile: %v", workspace.Display(), err)
}
}

View File

@@ -6,6 +6,7 @@ package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
@@ -19,22 +20,38 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
initCmd := NewCmdConfigInit(f, nil)
bind := NewCmdConfigBind(f, nil)
remove := NewCmdConfigRemove(f, nil)
show := NewCmdConfigShow(f, nil)
defaultAs := NewCmdConfigDefaultAs(f)
strictMode := NewCmdConfigStrictMode(f)
riskControl := NewCmdConfigRiskControl(f)
policy := NewCmdConfigPolicy(f)
plugins := NewCmdConfigPlugins(f)
keychainDowngrade := NewCmdConfigKeychainDowngrade(f)
// Identity preferences live in the Profile, but external providers have
// historically treated these config commands as credential management.
// Check Profile ownership first so a managed runtime gives the actionable
// deployment-managed Profile error, then retain the credential capability
// so Standard external-provider behavior stays unchanged.
for _, identitySetting := range []*cobra.Command{defaultAs, strictMode} {
cmdutil.SetRuntimeCapabilities(
identitySetting,
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
)
}
for _, sourceNeutral := range []*cobra.Command{show, riskControl, policy, plugins} {
cmdutil.SetRuntimeCapabilities(sourceNeutral)
}
cmd.AddCommand(initCmd, bind, remove, show, defaultAs, strictMode, riskControl, policy, plugins, keychainDowngrade)
return cmd
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
type noopConfigKeychain struct{}
@@ -452,10 +453,16 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct{ name string }
type stubConfigExtProvider struct {
name string
err error
}
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
if s.err != nil {
return nil, s.err
}
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
@@ -481,7 +488,6 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
@@ -509,6 +515,63 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}
}
func TestConfigIdentityCommandsCheckProfileOwnershipBeforeCredentialOwnership(t *testing.T) {
profileDenied := errors.New("Profile identity settings are deployment-managed")
credentialChecks := 0
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
switch capability {
case runtimeplan.CapabilityLocalProfileMutation:
return profileDenied
case runtimeplan.CapabilityLocalCredentialManagement:
credentialChecks++
}
return nil
},
})
for _, args := range [][]string{
{"default-as", "bot"},
{"strict-mode", "bot"},
} {
t.Run(args[0], func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdConfig(f)
cmd.SetArgs(args)
err := cmd.Execute()
if !errors.Is(err, profileDenied) {
t.Fatalf("Execute(%v) error = %v, want Profile ownership denial", args, err)
}
})
}
if credentialChecks != 0 {
t.Fatalf("credential capability checked %d times after Profile denial, want 0", credentialChecks)
}
}
func TestConfigIdentityCommandsRetainCredentialOwnershipCapability(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := NewCmdConfig(f)
for _, name := range []string{"default-as", "strict-mode"} {
t.Run(name, func(t *testing.T) {
leaf, _, err := root.Find([]string{name})
if err != nil {
t.Fatal(err)
}
got := cmdutil.GetRuntimeCapabilities(leaf)
want := []runtimeplan.Capability{
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("%s capabilities = %v, want %v", name, got, want)
}
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.

View File

@@ -27,13 +27,6 @@ func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
Use: "plugins",
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
Short: "Inspect installed plugins and their hook contributions",
// Same leaf-level no-op as config policy: the parent `config`
// group's PersistentPreRunE requires builtin credential, but
// this is a read-only diagnostic that must work everywhere.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPluginsShow(f))
return cmd

View File

@@ -16,12 +16,6 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
Use: "policy",
Hidden: true,
Short: "Inspect the user-layer command policy",
// Override parent's RequireBuiltinCredentialProvider check; this
// group is read-only diagnostic and must work under any provider.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPolicyShow(f))
return cmd

View File

@@ -132,19 +132,16 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
}
}
// Regression: the parent `config` command declares a PersistentPreRunE
// that calls RequireBuiltinCredentialProvider; env credentials cause
// it to return external_provider. `config policy` is a diagnostic
// group that must not be blocked by that check. The group declares
// its own no-op PersistentPreRunE so cobra's "first walking up from
// leaf" picks ours over the config parent's.
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
// The policy group explicitly overrides the config parent's local credential
// management capability because it is source-neutral diagnostics.
func TestConfigPolicyOverridesCredentialManagementCapability(t *testing.T) {
f, _, _ := newPolicyTestFactory()
group := NewCmdConfigPolicy(f)
if group.PersistentPreRunE == nil {
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
root := NewCmdConfig(f)
leaf, _, err := root.Find([]string{"policy", "show"})
if err != nil {
t.Fatal(err)
}
if err := group.PersistentPreRunE(group, nil); err != nil {
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
}
}

View File

@@ -23,11 +23,6 @@ func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {

View File

@@ -42,6 +42,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
// config show describes the effective invocation configuration, not merely
// the bytes in config.json. Preserve the typed bootstrap failure so a
// Standard binary cannot present a local Profile as active when the system
// requires Extended runtime support.
if startupErr := f.RuntimeStartupError(); startupErr != nil {
return startupErr
}
if handled, editionErr := showEditionConfig(f); handled {
return editionErr
}
config, err := core.LoadMultiAppConfig()
if err != nil {

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
type editionConfigShowResult struct {
Source string `json:"source"`
CredentialProvider string `json:"credentialProvider"`
Manageable bool `json:"manageable"`
Workspace string `json:"workspace"`
AppID string `json:"appId"`
Brand string `json:"brand"`
DefaultAs string `json:"defaultAs"`
Profile *string `json:"profile,omitempty"`
ExternalCredentialMode *string `json:"externalCredentialMode,omitempty"`
RemoteEndpoint *string `json:"remoteEndpoint,omitempty"`
}
func showEditionConfig(f *cmdutil.Factory) (bool, error) {
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return true, typedEditionProviderError("determine the active credential provider", err)
}
if source == nil || !source.Managed {
return false, nil
}
if source.AppID == "" {
return true, errs.NewInternalError(errs.SubtypeInvalidResponse,
"external credential provider %q returned no account", source.Name)
}
result := editionConfigShowResult{
Source: "external",
CredentialProvider: source.Name,
Manageable: false,
Workspace: core.CurrentWorkspace().Display(),
AppID: source.AppID,
Brand: string(source.Brand),
DefaultAs: string(source.DefaultAs),
}
description := f.RuntimeDescription()
if source.ProfileName != "" {
result.Profile = &source.ProfileName
}
if description.Managed && description.Variant != "" {
result.ExternalCredentialMode = &description.Variant
if description.ProxiesRequests {
result.RemoteEndpoint = &description.DataPlaneEndpoint
}
}
output.PrintJson(f.IOStreams.Out, result)
return true, nil
}
func typedEditionProviderError(action string, err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s: %v", action, err).WithCause(err)
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedConfigShowAllowedWithManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
cmd := NewCmdConfig(f)
matched, _, err := cmd.Find([]string{"show"})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("config show blocked: %v", err)
}
}
func TestExtendedConfigShowProjectsManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
var stdout bytes.Buffer
f.IOStreams.Out = &stdout
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
ProxiesRequests: true,
DataPlaneEndpoint: "https://managed.example.test",
},
}))
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun() error = %v", err)
}
var got editionConfigShowResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Source != "external" ||
got.CredentialProvider != "env" ||
got.Manageable ||
got.AppID != "test-app" ||
got.ExternalCredentialMode == nil ||
*got.ExternalCredentialMode != "managed-test" ||
got.RemoteEndpoint == nil ||
*got.RemoteEndpoint != "https://managed.example.test" {
t.Fatalf("output = %#v", got)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &fields); err != nil {
t.Fatal(err)
}
if _, ok := fields["appSecret"]; ok {
t.Fatalf("managed output must not invent appSecret: %s", stdout.String())
}
if _, ok := fields["users"]; ok {
t.Fatalf("managed output must not invent users: %s", stdout.String())
}
}
func TestExtendedConfigShowTypesManagedSourceFailure(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
providerErr := errors.New("provider failed")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&stubConfigExtProvider{name: "broken", err: providerErr}},
nil, nil, nil,
)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error = %#v, want internal/unknown", err)
}
if !errors.Is(err, providerErr) {
t.Fatalf("error does not preserve provider failure: %v", err)
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import "github.com/larksuite/cli/internal/cmdutil"
func showEditionConfig(*cmdutil.Factory) (bool, error) {
return false, nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestStandardConfigShowPreservesLocalConfigPath(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryConfig ||
problem.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("error = %#v, want established config/not_configured result", err)
}
}
func TestStandardConfigShowReturnsTypedRuntimeStartupError(t *testing.T) {
startupErr := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"system external credential configuration requires the lark-cli Extended edition",
).WithHint("install lark-cli Extended or ask the administrator to remove external-credential.json")
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(
t,
nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly),
)
err := configShowRun(&ConfigShowOptions{Factory: f})
if !errors.Is(err, startupErr) {
t.Fatalf("config show error = %v, want original startup error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeFailedPrecondition ||
problem.Message != "system external credential configuration requires the lark-cli Extended edition" {
t.Fatalf("config show problem = %#v, want typed Extended-required startup failure", problem)
}
if stdout.Len() != 0 {
t.Fatalf("config show wrote local Profile after bootstrap failure: %s", stdout.String())
}
}

View File

@@ -84,6 +84,10 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, checkCLIUpdate()...)
}
if handled, editionErr := runEditionDoctor(opts, checks); handled {
return editionErr
}
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
@@ -130,8 +134,7 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
// the source-appropriate remediation. A command here would be redundant.
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
@@ -215,7 +218,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := update.FetchLatest()
latest, err := fetchLatestForEdition()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
)
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
f := opts.Factory
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(opts.Ctx)
if err != nil {
checks = append(checks, fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)))
return true, finishDoctor(f, checks)
}
if source == nil || !source.Managed {
return false, nil
}
provider := source.Name
cfg, err := f.Config()
if err != nil {
checks = append(checks,
fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)),
skip("config_file", fmt.Sprintf("local credentials are not used; source is %s", provider)),
)
return true, finishDoctor(f, checks)
}
checks = append(checks, pass("credential_source",
fmt.Sprintf("credentials provided by %s (app %s; token not verified by this check)", provider, cfg.AppID)))
description := f.RuntimeDescription()
if description.Managed {
checks = append(checks, pass("config_file", "config.json found (system external credential mode)"))
} else {
checks = append(checks, skip("config_file",
fmt.Sprintf("local config not used; credentials provided by %s", provider)))
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
)
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
if description.ProxiesRequests {
checks = append(checks, editionProxyNetworkCheck(opts, description.DataPlaneEndpoint, diagnostics))
} else {
checks = append(checks, networkChecks(opts.Ctx, opts, core.ResolveEndpoints(cfg.Brand))...)
}
return true, finishDoctor(f, checks)
}
func editionDiagnosticErrorHint(err error) string {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
return blockErr.Reason
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
return cfgErr.Hint
}
return ""
}
func editionProxyNetworkCheck(opts *DoctorOptions, endpoint string, diagnostics identitydiag.Result) checkResult {
if opts.Offline {
return skip("endpoint_external_platform", "skipped (--offline)")
}
verified := func(id identitydiag.Identity) bool { return id.Verified != nil && *id.Verified }
if verified(diagnostics.User) || verified(diagnostics.Bot) {
return pass("endpoint_external_platform", endpoint+" reachable through an authenticated API request")
}
return fail("endpoint_external_platform", endpoint+" could not complete an authenticated API request",
"check the external credential program and platform logs")
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"context"
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestExtendedProxyNetworkCheckUsesAuthenticatedDiagnostics(t *testing.T) {
verified := true
endpoint := "https://credentials.example.com"
got := editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{
User: identitydiag.Identity{Verified: &verified},
})
if got.Status != "pass" || got.Name != "endpoint_external_platform" {
t.Fatalf("check = %#v", got)
}
got = editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{})
if got.Status != "fail" {
t.Fatalf("unverified check = %#v, want fail", got)
}
}
func TestExtendedDoctorManagedSourceDoesNotRequireLocalConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
OK bool `json:"ok"`
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.OK {
t.Fatalf("checks = %#v", got.Checks)
}
assertCheck(t, got.Checks, "credential_source", "pass")
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "skip" ||
!strings.Contains(configCheck.Message, "local config") ||
strings.Contains(configCheck.Message, "config init") {
t.Fatalf("config_file = %#v", configCheck)
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/errs"
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
if opts == nil || opts.Factory == nil {
return false, nil
}
startupErr := opts.Factory.RuntimeStartupError()
if startupErr == nil {
return false, nil
}
hint := ""
if problem, ok := errs.ProblemOf(startupErr); ok {
hint = problem.Hint
}
checks = append(checks, fail("credential_source", startupErr.Error(), hint))
return true, finishDoctor(opts.Factory, checks)
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import (
"context"
"encoding/json"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardDoctorPreservesConfigFirstDiagnostics(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want established missing-config failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
assertCheck(t, got.Checks, "config_file", "fail")
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("Standard doctor exposed edition diagnostic: %#v", got.Checks)
}
}
}

View File

@@ -4,7 +4,6 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
@@ -175,6 +174,44 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
return nil, nil
}
type failingDefaultAccountResolver struct {
err error
}
func (r *failingDefaultAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, r.err
}
func TestDoctor_DefaultResolutionFailurePreservesConfigFileCheck(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
nil,
&failingDefaultAccountResolver{err: core.NotConfiguredError()},
nil,
nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want not-configured failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "fail" {
t.Fatalf("config_file = %#v, want fail", configCheck)
}
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("default source resolution replaced the legacy config check: %#v", got.Checks)
}
}
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
@@ -195,12 +232,8 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = cred
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import "github.com/larksuite/cli/internal/extendedupdate"
func fetchLatestForEdition() (string, error) {
return extendedupdate.FetchLatest()
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/internal/update"
func fetchLatestForEdition() (string, error) {
return update.FetchLatest()
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
func TestStandardDoctorReportsEditionSentinelWithoutLocalProfile(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configDir := t.TempDir()
systemPath := filepath.Join(configDir, "external-credential.json")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
if err := vfs.WriteFile(systemPath, []byte("sentinel-only"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
root := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &stdout, &stderr),
WithoutPlugins(),
WithoutServiceCommands(),
)
root.SetArgs([]string{"doctor", "--offline"})
if err := root.ExecuteContext(context.Background()); err == nil {
t.Fatal("doctor returned nil, want failed diagnostic result")
}
var report struct {
Checks []struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode doctor output: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
}
for _, check := range report.Checks {
if check.Name != "credential_source" {
continue
}
if check.Status != "fail" ||
check.Message != "system external credential configuration requires the lark-cli Extended edition" ||
!strings.Contains(check.Hint, "install lark-cli Extended") {
t.Fatalf("credential_source check = %#v", check)
}
if strings.Contains(stdout.String(), "config init") {
t.Fatalf("doctor suggested local credential bootstrap for an edition sentinel: %s", stdout.String())
}
return
}
t.Fatalf("doctor did not report the edition sentinel: %s", stdout.String())
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands owns the Extended-only command surface.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestExtendedRegistersVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Extended edition commands = %v, want [version]", commands)
}
if commands[0].Hidden {
t.Fatal("Extended version command must be visible")
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands keeps the release identity probe callable in
// Standard while cmd/version hides it from help. This preserves the ordinary
// command surface and gives installers/CI one edition-neutral verification
// contract.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestStandardRegistersHiddenVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Standard edition commands = %v, want [version]", commands)
}
if !commands[0].Hidden {
t.Fatal("Standard version command must remain hidden")
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
@@ -16,14 +17,31 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
// Without SilenceUsage, RunE errors print the full flag help banner.
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
// This hook shadows root's PersistentPreRun, so preserve the matched
// command for structured error and declared-scope hints.
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityRealtimeEvents)
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
consume := NewCmdConsume(f)
bus := NewCmdBus(f)
list := NewCmdList(f)
schema := NewCmdSchema(f)
status := NewCmdStatus(f)
stop := NewCmdStop(f)
for _, local := range []*cobra.Command{list, schema, status, stop} {
cmdutil.SetRuntimeCapabilities(local)
}
cmd.AddCommand(consume)
cmd.AddCommand(list)
cmd.AddCommand(schema)
cmd.AddCommand(status)
cmd.AddCommand(stop)
cmd.AddCommand(bus)
return cmd
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"io/fs"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
func TestEventCommandsRejectDeniedRuntimeCapability(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_runtime_event_test",
AppSecret: "must-not-be-used",
Brand: core.BrandFeishu,
}
denied := errs.NewValidationError(errs.SubtypeFailedPrecondition,
"real-time events are unavailable in this runtime").
WithHint("use a runtime that supports real-time events")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
t.Run("consume", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"consume", "guarded-before-event-lookup"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
})
t.Run("bus", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"_bus"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
if _, err := vfs.Stat(filepath.Join(configDir, "events")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("event bus created runtime files before guard: %v", err)
}
})
}
func TestEventCommandRuntimeCapabilityMatrix(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdEvents(f)
parentCapabilities := cmdutil.GetRuntimeCapabilities(cmd)
if len(parentCapabilities) != 1 || parentCapabilities[0] != runtimeplan.CapabilityRealtimeEvents {
t.Fatalf("event capabilities = %v, want [%s]", parentCapabilities, runtimeplan.CapabilityRealtimeEvents)
}
wantRealtime := map[string]bool{
"_bus": true,
"consume": true,
"list": false,
"schema": false,
"status": false,
"stop": false,
}
children := make(map[string]*cobra.Command, len(wantRealtime))
for _, child := range cmd.Commands() {
name := child.Name()
want, ok := wantRealtime[name]
if !ok {
t.Fatalf("event command %q is missing from the runtime capability matrix", name)
}
children[name] = child
got := cmdutil.GetRuntimeCapabilities(child)
if want {
if len(got) != 1 || got[0] != runtimeplan.CapabilityRealtimeEvents {
t.Errorf("event %s capabilities = %v, want [%s]", name, got, runtimeplan.CapabilityRealtimeEvents)
}
continue
}
if len(got) != 0 {
t.Errorf("event %s capabilities = %v, want source-neutral local command", name, got)
}
}
if len(children) != len(wantRealtime) {
t.Fatalf("event command matrix covered %d commands, want %d", len(children), len(wantRealtime))
}
// Clearing the parent declaration must also clear both consumers. This
// proves they inherit the fail-closed default instead of duplicating a
// leaf annotation that future event commands could forget.
cmdutil.SetRuntimeCapabilities(cmd)
for _, name := range []string{"consume", "_bus"} {
if got := cmdutil.GetRuntimeCapabilities(children[name]); len(got) != 0 {
t.Errorf("event %s capabilities after clearing parent = %v, want inherited empty declaration", name, got)
}
}
}
func requireExternalEventGuard(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed problem", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want %s/%s",
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "" {
t.Fatalf("param = %q, want empty", validationErr.Param)
}
if problem.Hint == "" {
t.Fatal("hint is empty")
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdProfile creates the profile command with subcommands.
@@ -14,13 +15,26 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// A child PersistentPreRunE shadows root's PersistentPreRun, so retain
// the invocation state used by structured error hints here.
cmd.SilenceUsage = true
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalProfileMutation)
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
})
cmd.AddCommand(NewCmdProfileList(f))
list := NewCmdProfileList(f)
// Listing profiles is read-only and remains useful for diagnostics under a
// managed credential runtime. Every other profile subcommand mutates local
// profile selection, config, or keychain state and inherits the parent gate.
cmdutil.SetRuntimeCapabilities(list)
cmd.AddCommand(list)
cmd.AddCommand(NewCmdProfileUse(f))
cmd.AddCommand(NewCmdProfileAdd(f))
cmd.AddCommand(NewCmdProfileRemove(f))

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"bytes"
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
type recordingProfileKeychain struct {
gets int
sets int
removes int
}
func (k *recordingProfileKeychain) Get(_, _ string) (string, error) {
k.gets++
return "", nil
}
func (k *recordingProfileKeychain) Set(_, _, _ string) error {
k.sets++
return nil
}
func (k *recordingProfileKeychain) Remove(_, _ string) error {
k.removes++
return nil
}
func TestProfileMutationCommandsAreDeniedBeforeLocalStateChanges(t *testing.T) {
denied := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
).WithHint("manage credentials through the active provider")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return denied
}
return nil
},
})
tests := []struct {
name string
args []string
}{
{
name: "add",
args: []string{"add", "--name", "new", "--app-id", "app-new", "--app-secret-stdin"},
},
{
name: "use",
args: []string{"use", "target"},
},
{
name: "rename",
args: []string{"rename", "target", "renamed"},
},
{
name: "remove",
args: []string{"remove", "target"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configDir := setupProfileConfigDir(t)
saveManagedGateFixture(t)
configPath := filepath.Join(configDir, "config.json")
before, err := vfs.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(before) error = %v", err)
}
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
f.IOStreams.In = strings.NewReader("must-not-be-read\n")
keychain := &recordingProfileKeychain{}
f.Keychain = keychain
cmd := NewCmdProfile(f)
cmd.SetArgs(tt.args)
err = cmd.Execute()
if !errors.Is(err, denied) {
t.Fatalf("Execute() error = %v, want denied runtime error", err)
}
after, readErr := vfs.ReadFile(configPath)
if readErr != nil {
t.Fatalf("ReadFile(after) error = %v", readErr)
}
if !bytes.Equal(after, before) {
t.Fatalf("config changed despite runtime denial:\nbefore: %s\nafter: %s", before, after)
}
if keychain.gets != 0 || keychain.sets != 0 || keychain.removes != 0 {
t.Fatalf("keychain calls = get:%d set:%d remove:%d, want none",
keychain.gets, keychain.sets, keychain.removes)
}
})
}
}
func TestProfileListRemainsAvailableWhenLocalMutationIsDenied(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
)
}
return nil
},
})
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdProfile(f)
cmd.SetArgs([]string{"list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list was blocked by mutation capability: %v", err)
}
if !strings.Contains(stdout.String(), `"name": "default"`) {
t.Fatalf("profile list output = %s, want default profile", stdout.String())
}
}
func TestProfileMutationCommandsRemainAvailableByDefault(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
// origin/main allows Profile preparation while an environment/extension
// provider is active. The managed runtime blocks this through its explicit
// plan policy; generic provider ownership must not change Standard.
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{profileEnvironmentProvider{}},
nil,
nil,
nil,
)
cmd := NewCmdProfile(f)
args := []string{"use", "target"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use with default runtime plan error = %v", err)
}
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "target" || saved.PreviousApp != "default" {
t.Fatalf("selection = current:%q previous:%q, want target/default",
saved.CurrentApp, saved.PreviousApp)
}
}
type profileEnvironmentProvider struct{}
func (profileEnvironmentProvider) Name() string { return "env" }
func (profileEnvironmentProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{
AppID: "cli_environment",
Brand: extcred.BrandFeishu,
SupportedIdentities: extcred.SupportsAll,
}, nil
}
func (profileEnvironmentProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: "environment-token"}, nil
}
func saveManagedGateFixture(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.SecretInput{Ref: &core.SecretRef{
Source: "keychain",
ID: "appsecret:app-target",
}},
Brand: core.BrandLark,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -100,6 +101,12 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
// Resolve all startup state from the detected workspace. This must happen
// before ResolveStartupBrand, isSingleAppMode, or buildInternal reads
// workspace-scoped configuration.
selectInvocationWorkspace()
startup := runtimebootstrap.Resolve(inv.Profile)
startupBrand := resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
configureFlagCompletions(os.Args)
ctx := context.Background()
@@ -107,7 +114,8 @@ func Execute() int {
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
WithStartupBrand(startupBrand),
withRuntimeBootstrap(startup),
)
// --- Notices (non-blocking) ---
@@ -137,7 +145,7 @@ func Execute() int {
// or both may be present in any given envelope.
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := update.CheckCached(build.Version); info != nil {
if info := checkCachedEditionUpdate(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
@@ -147,9 +155,9 @@ func setupNotices() {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
update.RefreshCache(ver)
refreshEditionUpdateCache(ver)
if update.GetPending() == nil {
if info := update.CheckCached(ver); info != nil {
if info := checkCachedEditionUpdate(ver); info != nil {
update.SetPending(info)
}
}

View File

@@ -18,6 +18,7 @@ import (
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -435,6 +436,25 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
}
}
func TestHandleRootError_BlockErrorPreservesUntypedFallback(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
blockErr := &extcred.BlockError{Provider: "env", Reason: "LARKSUITE_CLI_APP_ID is missing"}
exit := handleRootError(f, blockErr)
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if errObj["type"] != "internal" || errObj["subtype"] != "unknown" {
t.Fatalf("error = %#v", errObj)
}
if errObj["message"] != "blocked by env: LARKSUITE_CLI_APP_ID is missing" {
t.Fatalf("error.message = %v", errObj["message"])
}
if exit != int(output.ExitInternal) {
t.Fatalf("exit = %d, want %d", exit, output.ExitInternal)
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -58,24 +57,14 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
// Gate 4: cached newer version from this binary's release channel.
// Standard reads the npm-backed cache; Extended reads its separate
// GitHub-release cache.
info := checkCachedEditionUpdate(build.Version)
if info == nil {
return
}
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -6,7 +6,6 @@ package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -15,13 +14,21 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
func updateStateFileForEdition(edition string) string {
if edition == "extended" {
return "update-state-extended.json"
}
return "update-state.json"
}
func writeUpdateState(t *testing.T, dir, edition, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
if err := vfs.WriteFile(filepath.Join(dir, updateStateFileForEdition(edition)), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
@@ -105,7 +112,7 @@ func TestOfferRootUpgrade(t *testing.T) {
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
writeUpdateState(t, dir, build.Edition, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
@@ -128,17 +135,6 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
@@ -146,6 +142,53 @@ func TestOfferRootUpgrade(t *testing.T) {
}
}
func TestOfferRootUpgradeIgnoresOtherEditionCache(t *testing.T) {
origV := build.Version
build.Version = "1.0.0"
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
otherEdition := "extended"
if build.Edition == "extended" {
otherEdition = "standard"
}
writeUpdateState(t, dir, otherEdition, "9.0.0")
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: true,
OutIsTerminal: true,
StderrIsTerminal: true,
}}
offerRootUpgrade(f, &cobra.Command{})
if strings.Contains(errBuf.String(), "available") {
t.Fatalf("%s prompt consumed %s cache: %q", build.Edition, otherEdition, errBuf.String())
}
if called {
t.Fatalf("%s upgrade ran from %s cache", build.Edition, otherEdition)
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })

View File

@@ -10,17 +10,34 @@ import (
"github.com/larksuite/cli/internal/envvars"
)
// selectInvocationWorkspace establishes the workspace before any startup
// consumer reads workspace-scoped configuration. Execute needs this before
// resolving the registry brand, while Build/buildInternal needs it before
// capturing the immutable Profile snapshot passed to the Factory.
func selectInvocationWorkspace() core.Workspace {
workspace := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(workspace)
return workspace
}
// ResolveStartupBrand resolves the brand before the command tree is built, so
// the registry's remote metadata overlay uses the configured brand from the
// first catalog access. It mirrors the credential chain's brand precedence —
// environment, then the active profile's raw config entry — without touching
// the keychain (no secrets are needed to know the brand).
func ResolveStartupBrand(profile string) core.LarkBrand {
config, _ := core.LoadMultiAppConfig()
return resolveStartupBrandFromConfig(profile, config)
}
// resolveStartupBrandFromConfig keeps registry routing on the same immutable
// Profile snapshot used by credentials and runtime policy.
func resolveStartupBrandFromConfig(profile string, config *core.MultiAppConfig) core.LarkBrand {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
}
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
if config != nil {
if app := config.CurrentAppConfig(profile); app != nil {
return core.ParseBrand(string(app.Brand))
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
)
var (
fetchExtendedLatest = extendedupdate.FetchLatest
installExtended = extendedupdate.Install
)
func updateLongDescription() string {
return `Update lark-cli Extended from the matching GitHub Release.
The command downloads the lark-cli-extended asset for the current platform,
verifies its SHA-256 checksum and compiled edition identity, then replaces the
current binary. It never installs the Standard npm/npx edition.
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}
func runEditionUpdate(opts *UpdateOptions) (bool, error) {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
if !opts.Check {
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
latest, err := fetchExtendedLatest()
if err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "network", typed)
}
return true, reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to check the latest Extended version: %v", err).WithCause(err))
}
if update.ParseVersion(latest) == nil {
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse,
"invalid Extended version from GitHub Releases: %s", latest))
}
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return true, reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
if opts.Check {
return true, reportCheckResult(opts, io, cur, latest, true)
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli Extended %s %s %s from GitHub Releases ...\n", cur, symArrow(), latest)
}
if err := installExtended(latest); err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "update_error", typed)
}
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeUnknown,
"failed to install lark-cli Extended: %v", err).WithCause(err))
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "edition": build.Edition, "action": "updated",
"message": fmt.Sprintf("lark-cli Extended updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsResult(result, skillsResult)
output.PrintJson(io.Out, result)
return true, nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli Extended from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
emitSkillsTextHints(io, skillsResult)
return true, nil
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
)
func TestExtendedUpdateUsesExtendedReleaseInstaller(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion, oldUpdater, oldSync := currentVersion, newUpdater, syncSkills
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion, newUpdater, syncSkills = oldVersion, oldUpdater, oldSync
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installed := ""
installExtended = func(version string) error {
installed = version
return nil
}
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallManual}
}}
}
syncSkills = func(skillscheck.SyncOptions) *skillscheck.SyncResult { return &skillscheck.SyncResult{} }
var out, errOut bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &errOut), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if installed != "1.2.4" {
t.Fatalf("installed version = %q, want 1.2.4", installed)
}
var result map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["edition"] != "extended" || result["action"] != "updated" {
t.Fatalf("result = %#v", result)
}
}
func TestExtendedUpdateCheckDoesNotInstall(t *testing.T) {
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion := currentVersion
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion = oldVersion
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installExtended = func(string) error {
t.Fatal("installer called during --check")
return nil
}
var out bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &bytes.Buffer{}), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
func runEditionUpdate(*UpdateOptions) (bool, error) { return false, nil }
func updateLongDescription() string {
return `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}

View File

@@ -101,15 +101,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
Long: updateLongDescription(),
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
@@ -124,6 +116,9 @@ Use --check to only check for updates without installing.`,
}
func updateRun(opts *UpdateOptions) error {
if handled, err := runEditionUpdate(opts); handled {
return err
}
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
import (

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/update"
)
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return extendedupdate.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
extendedupdate.RefreshCache(currentVersion)
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import "github.com/larksuite/cli/internal/update"
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return update.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
update.RefreshCache(currentVersion)
}

56
cmd/version/version.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
type options struct {
factory *cmdutil.Factory
json bool
}
type versionReport struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
// NewCmdVersion reports the immutable edition identity compiled into the
// binary. Root --version remains unchanged for compatibility.
func NewCmdVersion(f *cmdutil.Factory) *cobra.Command {
opts := &options{factory: f}
cmd := &cobra.Command{
Use: "version",
Short: "Show version and edition information",
Hidden: hideVersionCommand(),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.json {
output.PrintJson(opts.factory.IOStreams.Out, versionReport{
Version: build.Version,
Edition: build.Edition,
Capabilities: build.Capabilities(),
})
return nil
}
_, err := fmt.Fprintf(opts.factory.IOStreams.Out, "lark-cli version %s (%s)\n", build.Version, build.Edition)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write version output: %v", err).WithCause(err)
}
return nil
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "structured JSON output")
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestVersionJSONReportsCompiledEdition(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
var got struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Version != build.Version || got.Edition != build.Edition || !reflect.DeepEqual(got.Capabilities, build.Capabilities()) {
t.Fatalf("version output = %#v, want version=%q edition=%q", got, build.Version, build.Edition)
}
}
func TestVersionVisibilityPreservesStandardHelpSurface(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
wantHidden := build.Edition == "standard"
if cmd.Hidden != wantHidden {
t.Fatalf("version command hidden = %v, want %v for %s", cmd.Hidden, wantHidden, build.Edition)
}
}
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestVersionTextWriteFailureIsTyped(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
writeErr := errors.New("write failed")
f.IOStreams.Out = failingWriter{err: writeErr}
cmd := NewCmdVersion(f)
err := cmd.ExecuteContext(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeSDKError {
t.Fatalf("error = %#v, want internal/sdk_error", err)
}
if !errors.Is(err, writeErr) {
t.Fatalf("error does not preserve write failure: %v", err)
}
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package version
func hideVersionCommand() bool { return false }

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package version
// Standard keeps its historical help surface unchanged. The command remains
// directly callable for release identity verification.
func hideVersionCommand() bool { return true }

View File

@@ -62,6 +62,8 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.message` | informational | not safe to branch on |
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.origin` | informational | Extended producer: `cli`, `credential_process`, `proxy`, or `lark`; omitted by Standard to preserve its existing envelope; consumers must tolerate absence and unknown future values |
| `error.proxy_request_id` | informational | external credential platform trace id; never stored in `log_id` |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
@@ -104,7 +106,7 @@ already succeeded).
| `config` | local config missing / unbound | 3 | `ConfigError` |
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `policy` | security policy denial/challenge, including content safety | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
@@ -272,7 +274,7 @@ legal for framework dynamic paths (e.g. classifier fanout) but the lint
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
| Transport or external dependency failure | `errs.NewNetworkError(subtype, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport` / `credential_source_unavailable` / `upstream_unavailable`) |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
@@ -513,7 +515,11 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
typed struct as a documented extension field, not into the envelope's
top level.
top level. The external credential platform contract is the single explicit
exception: `origin` and `proxy_request_id` are shared across several error
categories and therefore live in `Problem`. Both fields are optional, and
consumers must ignore them when absent or unknown. Any further shared field
still requires an explicit contract revision and wire-format pin.
## CI guards

134
errs/diagnostic_metadata.go Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
)
// DiagnosticMetadata carries optional producer diagnostics without changing
// the field layout of Problem or any concrete typed error. Keeping this
// metadata in a wrapper preserves source compatibility for callers that use
// positional literals of the existing exported error structs.
type DiagnosticMetadata struct {
Origin string
ProxyRequestID string
}
type diagnosticMetadataWrapper struct {
err error
typed error
metadata DiagnosticMetadata
}
func (e *diagnosticMetadataWrapper) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *diagnosticMetadataWrapper) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func (e *diagnosticMetadataWrapper) ProblemDetail() *Problem {
if e == nil {
return nil
}
problem, _ := ProblemOf(e.typed)
return problem
}
func (e *diagnosticMetadataWrapper) DiagnosticMetadata() DiagnosticMetadata {
if e == nil {
return DiagnosticMetadata{}
}
return e.metadata
}
// MarshalJSON preserves the concrete typed error's extension fields and adds
// the optional diagnostics as sibling fields in the existing error object.
func (e *diagnosticMetadataWrapper) MarshalJSON() ([]byte, error) {
raw, err := json.Marshal(e.typed)
if err != nil {
return nil, err
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if e.metadata.Origin != "" {
origin, err := json.Marshal(e.metadata.Origin)
if err != nil {
return nil, err
}
object["origin"] = origin
}
if e.metadata.ProxyRequestID != "" {
requestID, err := json.Marshal(e.metadata.ProxyRequestID)
if err != nil {
return nil, err
}
object["proxy_request_id"] = requestID
}
return json.Marshal(object)
}
// WithDiagnosticMetadata attaches optional wire diagnostics to a typed error.
// Empty metadata is a no-op. The returned wrapper still participates in
// errors.Is/errors.As and TypedError routing through Unwrap and ProblemDetail.
func WithDiagnosticMetadata(err error, metadata DiagnosticMetadata) error {
if err == nil || (metadata.Origin == "" && metadata.ProxyRequestID == "") {
return err
}
typed, ok := UnwrapTypedError(err)
if !ok {
return err
}
if existing, ok := diagnosticMetadataWrapperForProducer(typed); ok {
merged := existing.metadata
if metadata.Origin != "" {
merged.Origin = metadata.Origin
}
if metadata.ProxyRequestID != "" {
merged.ProxyRequestID = metadata.ProxyRequestID
}
return &diagnosticMetadataWrapper{err: err, typed: existing.typed, metadata: merged}
}
return &diagnosticMetadataWrapper{err: err, typed: typed, metadata: metadata}
}
// DiagnosticMetadataOf returns optional diagnostics attached to the first
// typed producer in err's wrap chain. Metadata on a typed cause belongs to
// that inner producer and must not be projected onto an outer typed error.
func DiagnosticMetadataOf(err error) (DiagnosticMetadata, bool) {
typed, ok := UnwrapTypedError(err)
if !ok {
return DiagnosticMetadata{}, false
}
carrier, ok := diagnosticMetadataWrapperForProducer(typed)
if !ok {
return DiagnosticMetadata{}, false
}
metadata := carrier.DiagnosticMetadata()
if metadata.Origin == "" && metadata.ProxyRequestID == "" {
return DiagnosticMetadata{}, false
}
return metadata, true
}
// diagnosticMetadataWrapperForProducer deliberately checks only the selected
// typed producer. errors.As must not be used here because it would traverse
// into an inner typed cause and associate that cause's metadata with the outer
// producer.
func diagnosticMetadataWrapperForProducer(typed error) (*diagnosticMetadataWrapper, bool) {
wrapper, ok := typed.(*diagnosticMetadataWrapper) //nolint:errorlint // Exact producer identity is the invariant being enforced.
return wrapper, ok
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"errors"
"fmt"
"testing"
)
func TestDiagnosticMetadataPreservesTypedErrorContract(t *testing.T) {
permission := NewPermissionError(SubtypeMissingScope, "missing scope").
WithMissingScopes("im:message")
wrapped := WithDiagnosticMetadata(permission, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_1",
})
var gotPermission *PermissionError
if !errors.As(wrapped, &gotPermission) || gotPermission != permission {
t.Fatalf("errors.As() = %p, want original permission error %p", gotPermission, permission)
}
problem, ok := ProblemOf(wrapped)
if !ok || problem != &permission.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want original Problem %p", problem, ok, &permission.Problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "proxy" || metadata.ProxyRequestID != "proxy_req_1" {
t.Fatalf("DiagnosticMetadataOf() = (%#v, %v)", metadata, ok)
}
raw, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
if object["origin"] != "proxy" || object["proxy_request_id"] != "proxy_req_1" {
t.Fatalf("metadata missing from JSON: %s", raw)
}
missingScopes, ok := object["missing_scopes"].([]any)
if !ok || len(missingScopes) != 1 || missingScopes[0] != "im:message" {
t.Fatalf("typed extension fields missing from JSON: %s", raw)
}
}
func TestDiagnosticMetadataMergesWithoutMutatingExistingWrapper(t *testing.T) {
typed := NewNetworkError(SubtypeUpstreamUnavailable, "unavailable")
withOrigin := WithDiagnosticMetadata(typed, DiagnosticMetadata{Origin: "proxy"})
withRequestID := WithDiagnosticMetadata(withOrigin, DiagnosticMetadata{ProxyRequestID: "proxy_req_2"})
original, _ := DiagnosticMetadataOf(withOrigin)
if original.ProxyRequestID != "" {
t.Fatalf("existing wrapper was mutated: %#v", original)
}
merged, ok := DiagnosticMetadataOf(withRequestID)
if !ok || merged.Origin != "proxy" || merged.ProxyRequestID != "proxy_req_2" {
t.Fatalf("merged metadata = (%#v, %v)", merged, ok)
}
}
func TestDiagnosticMetadataPreservesOuterErrorContext(t *testing.T) {
cause := errors.New("transport failed")
typed := NewNetworkError(SubtypeNetworkTransport, "request failed").WithCause(cause)
outer := fmt.Errorf("fetch document: %w", typed)
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "proxy"})
if got, want := wrapped.Error(), outer.Error(); got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
if !errors.Is(wrapped, cause) {
t.Fatal("metadata wrapper lost the original cause chain")
}
var gotTyped *NetworkError
if !errors.As(wrapped, &gotTyped) || gotTyped != typed {
t.Fatalf("errors.As() = %p, want original typed error %p", gotTyped, typed)
}
}
func TestDiagnosticMetadataDoesNotCrossTypedProducerBoundary(t *testing.T) {
inner := NewNetworkError(SubtypeUpstreamUnavailable, "proxy unavailable")
annotatedInner := WithDiagnosticMetadata(inner, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_inner",
})
outer := NewInternalError(SubtypeUnknown, "business reclassified failure").
WithCause(annotatedInner)
if metadata, ok := DiagnosticMetadataOf(outer); ok {
t.Fatalf("outer typed producer inherited inner metadata: %#v", metadata)
}
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "cli"})
problem, ok := ProblemOf(wrapped)
if !ok || problem != &outer.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want outer Problem %p", problem, ok, &outer.Problem)
}
if problem.Category != CategoryInternal ||
problem.Subtype != SubtypeUnknown ||
problem.Message != "business reclassified failure" {
t.Fatalf("outer typed identity changed: %#v", problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "cli" || metadata.ProxyRequestID != "" {
t.Fatalf("outer metadata = (%#v, %v), want cli without inner request id", metadata, ok)
}
innerMetadata, ok := DiagnosticMetadataOf(annotatedInner)
if !ok ||
innerMetadata.Origin != "proxy" ||
innerMetadata.ProxyRequestID != "proxy_req_inner" {
t.Fatalf("inner metadata was mutated: (%#v, %v)", innerMetadata, ok)
}
}

View File

@@ -27,7 +27,11 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
Identity: "user",
ConsoleURL: "https://example",
}
b, err := json.Marshal(pe)
withMetadata := WithDiagnosticMetadata(pe, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_123",
})
b, err := json.Marshal(withMetadata)
if err != nil {
t.Fatal(err)
}
@@ -39,6 +43,8 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
`"message":"x"`,
`"hint":"y"`,
`"log_id":"lg"`,
`"origin":"proxy"`,
`"proxy_request_id":"proxy_req_123"`,
`"missing_scopes":["docx:document"]`,
`"identity":"user"`,
`"console_url":"https://example"`,

View File

@@ -48,11 +48,13 @@ const (
// CategoryNetwork subtypes
const (
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
SubtypeCredentialSourceUnavailable Subtype = "credential_source_unavailable" // external credential program or identity service is temporarily unavailable
SubtypeUpstreamUnavailable Subtype = "upstream_unavailable" // external proxy cannot reach the requested upstream service
)
// CategoryAPI subtypes

View File

@@ -40,23 +40,15 @@ func MaskToken(token string) string {
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
token, _ := readStoredToken(appId, userOpenId)
return token
}
func readStoredToken(appId, userOpenId string) (*StoredUAToken, error) {
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if err != nil {
return nil, err
}
if jsonStr == "" {
return nil, nil
if err != nil || jsonStr == "" {
return nil
}
var token StoredUAToken
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
return nil, err
return nil
}
return &token, nil
return &token
}
// SetStoredToken persists a UAT.
@@ -74,54 +66,6 @@ func RemoveStoredToken(appId, userOpenId string) error {
return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId))
}
// sameStoredTokenGeneration reports whether two snapshots represent the same
// refresh-token generation. Access tokens are used only for case that does not
// contain a refresh token.
func isSameStoredTokenGeneration(current, expected *StoredUAToken) bool {
if current == nil || expected == nil ||
current.AppId != expected.AppId ||
current.UserOpenId != expected.UserOpenId {
return false
}
if current.RefreshToken != "" || expected.RefreshToken != "" {
return current.RefreshToken == expected.RefreshToken
}
return current.AccessToken == expected.AccessToken
}
// setStoredTokenIfCurrent stores updated only when expected is still the
// current token generation. It returns the token present after the check and
// whether the update was applied.
func setStoredTokenIfCurrent(expected, updated *StoredUAToken) (*StoredUAToken, bool, error) {
current, err := readStoredToken(expected.AppId, expected.UserOpenId)
if err != nil {
return nil, false, err
}
if !isSameStoredTokenGeneration(current, expected) {
return current, false, nil
}
if err := SetStoredToken(updated); err != nil {
return current, false, err
}
return updated, true, nil
}
// removeStoredTokenIfCurrent removes expected only when it is still the
// current token generation. It returns the token retained on a mismatch.
func removeStoredTokenIfCurrent(expected *StoredUAToken) (*StoredUAToken, bool, error) {
current, err := readStoredToken(expected.AppId, expected.UserOpenId)
if err != nil {
return nil, false, err
}
if !isSameStoredTokenGeneration(current, expected) {
return current, false, nil
}
if err := RemoveStoredToken(expected.AppId, expected.UserOpenId); err != nil {
return current, false, err
}
return nil, true, nil
}
// TokenStatus determines the freshness of a stored token.
func TokenStatus(token *StoredUAToken) string {
now := time.Now().UnixMilli()

View File

@@ -4,18 +4,17 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gofrs/flock"
@@ -82,7 +81,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
}
if status == "needs_refresh" {
refreshed, err := refreshWithLock(httpClient, opts)
refreshed, err := refreshWithLock(httpClient, opts, stored)
if err != nil {
return "", err
}
@@ -104,7 +103,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
}
// refreshWithLock acquires a file lock before attempting to refresh the token.
func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUAToken, error) {
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
// 1. Process-level lock (prevents multiple goroutines in the same process)
@@ -126,9 +125,12 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUATok
refreshLocks.Delete(key)
}()
// 2. Cross-process lock using the global config directory so all
// workspaces sharing the same token also share the same lock.
lockDir := filepath.Join(core.GetBaseConfigDir(), "locks")
// 2. Cross-process lock using flock
// We use the same underlying storage directory resolution as keychain_other.go
// to ensure locks are isolated properly alongside other sensitive data.
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}
@@ -151,91 +153,21 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUATok
}
defer fileLock.Unlock()
// 3. Re-read under the global lock and use only the current generation.
freshStored, err := readStoredToken(opts.AppId, opts.UserOpenId)
if err != nil {
return nil, err
}
if freshStored == nil {
return nil, nil
}
switch TokenStatus(freshStored) {
case "valid":
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
// 3. Double-checked locking: Check if another process has already refreshed the token
freshStored := GetStoredToken(opts.AppId, opts.UserOpenId)
if freshStored != nil {
status := TokenStatus(freshStored)
if status == "valid" {
// Another process refreshed it, we can just use the new token
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
}
return freshStored, nil
case "expired":
retained, removed, err := removeStoredTokenIfCurrent(freshStored)
if err != nil {
return nil, err
}
if !removed {
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
}
return nil, nil
}
if err := ensureDirWritable(lockDir, "tmp_writetest-*"); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: refresh lock directory is not writable while refreshing: %v\n",
err)
}
return nil, err
}
// 4. Actually perform the refresh
return doRefreshToken(httpClient, opts, freshStored)
}
const refreshMaxAttempts = 2
type refreshRequest struct {
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// refreshResponse contains only fields documented by the OAuth token endpoint.
// Pointers distinguish an omitted numeric field from a real zero value.
type refreshResponse struct {
Code *int `json:"code"`
AccessToken string `json:"access_token"`
ExpiresIn *int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// refreshAction describes both retry behavior and local token disposition.
type refreshAction uint8
const (
// refreshSaveResponse saves a successful response.
refreshSaveResponse refreshAction = iota
// refreshRetryAndPreserve retries, preserving the stored token if retry fails.
refreshRetryAndPreserve
// refreshRetryAndClear retries, clearing the stored token if retry fails.
refreshRetryAndClear
// refreshStopAndPreserve stops without clearing the stored token.
refreshStopAndPreserve
// refreshStopAndClear stops and clears the stored token.
refreshStopAndClear
)
type refreshResult struct {
action refreshAction
response refreshResponse
err error
return doRefreshToken(httpClient, opts, stored)
}
// doRefreshToken performs the actual HTTP request to refresh the token.
@@ -245,318 +177,141 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
errOut = os.Stderr
}
if time.Now().UnixMilli() >= stored.RefreshExpiresAt {
now := time.Now().UnixMilli()
if now >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
retained, removed, err := removeStoredTokenIfCurrent(stored)
if err != nil {
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
return nil, err
}
if !removed {
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
return nil, nil
}
endpoint := ResolveOAuthEndpoints(opts.Domain).Token
uncertain := false
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
result := refreshOnce(httpClient, endpoint, opts, stored)
if result.action == refreshSaveResponse {
return saveRefreshResponse(opts, stored, result.response)
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
switch result.action {
case refreshRetryAndPreserve, refreshRetryAndClear:
if result.action == refreshRetryAndClear {
uncertain = true
}
if attempt < refreshMaxAttempts {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh attempt %d/%d failed for %s: %v; retrying\n",
attempt, refreshMaxAttempts, opts.UserOpenId, result.err)
continue
}
case refreshStopAndPreserve, refreshStopAndClear:
default:
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"unrecognized token refresh action %d", result.action)
}
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
clearToken := result.action == refreshStopAndClear ||
result.action == refreshRetryAndClear ||
(result.action == refreshRetryAndPreserve && uncertain)
if !clearToken {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, preserving token: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
}
if problem, ok := errs.ProblemOf(result.err); ok {
problem.Retryable = false
}
retained, removed, err := removeStoredTokenIfCurrent(stored)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
return nil, err
}
if !removed {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, token cleared: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("token refresh read error: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %w", err)
}
return data, nil
}
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token refresh exhausted attempts without a result")
}
func refreshOnce(httpClient *http.Client, endpoint string, opts UATCallOptions, stored *StoredUAToken) refreshResult {
payload, err := json.Marshal(refreshRequest{
GrantType: "refresh_token",
RefreshToken: stored.RefreshToken,
ClientID: opts.AppId,
ClientSecret: opts.AppSecret,
})
data, err := callEndpoint()
if err != nil {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to encode token refresh request: %v", err).
WithCause(err),
return nil, err
}
code := getInt(data, "code", -1)
meta, metaOK := errclass.LookupCodeMeta(code)
if metaOK && meta.Category == errs.CategoryPolicy {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
}
}
var wroteRequest atomic.Bool
trace := &httptrace.ClientTrace{
WroteRequest: func(httptrace.WroteRequestInfo) {
wroteRequest.Store(true)
},
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to create token refresh request: %v", err).
WithCause(err),
}
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
errStr := getStr(data, "error")
resp, err := httpClient.Do(req)
if err != nil {
action := refreshRetryAndPreserve
if wroteRequest.Load() {
action = refreshRetryAndClear
}
return refreshResult{action: action, err: err}
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewNetworkError(errs.SubtypeNetworkTransport,
"token refresh response read failed: %v", err).
WithRetryable().
WithCause(err),
}
}
var parsed refreshResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh returned invalid JSON: %v", err).
WithRetryable().
WithCause(err),
}
}
if parsed.Code == nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field code").
WithRetryable(),
}
}
code := *parsed.Code
if code != 0 {
if meta, ok := errclass.LookupCodeMeta(code); ok && meta.Category == errs.CategoryPolicy {
var policyFields struct {
ChallengeURL string `json:"challenge_url"`
CLIHint string `json:"cli_hint"`
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
_ = json.Unmarshal(body, &policyFields)
return refreshResult{
action: refreshStopAndPreserve,
err: &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: parsed.ErrorDescription,
Hint: policyFields.CLIHint,
},
ChallengeURL: policyFields.ChallengeURL,
},
code = getInt(data, "code", -1)
errStr = getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
}
message := parsed.ErrorDescription
if message == "" {
message = parsed.Error
}
// BuildAPIError accepts the common OpenAPI message key; OAuth names
// the same value error_description.
apiErr := errclass.BuildAPIError(map[string]any{
"code": code,
"msg": message,
}, errclass.ClassifyContext{
Brand: string(opts.Domain),
AppID: opts.AppId,
Identity: "user",
})
if authErr, ok := apiErr.(*errs.AuthenticationError); ok {
authErr.UserOpenID = opts.UserOpenId
}
return refreshResult{action: refreshActionForCode(code), err: apiErr}
}
if parsed.RefreshToken == "" {
parsed.RefreshToken = stored.RefreshToken
}
if parsed.AccessToken == "" {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field access_token").
WithRetryable(),
}
}
if parsed.ExpiresIn == nil || *parsed.ExpiresIn <= 0 {
parsed.ExpiresIn = new(int64)
*parsed.ExpiresIn = 7200 // 2 hours
}
if parsed.RefreshTokenExpiresIn == nil || *parsed.RefreshTokenExpiresIn <= 0 {
parsed.RefreshTokenExpiresIn = new(int64)
if stored.RefreshExpiresAt <= 0 {
*parsed.RefreshTokenExpiresIn = 2592000 // 30 days
// Retry succeeded, fall through to parse token below.
} else {
now := time.Now().UnixMilli()
*parsed.RefreshTokenExpiresIn = (stored.RefreshExpiresAt - now) / 1000
// All other errors: clear token, require re-authorization.
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
}
return refreshResult{action: refreshSaveResponse, response: parsed}
}
func refreshActionForCode(code int) refreshAction {
meta, ok := errclass.LookupCodeMeta(code)
switch {
case !ok:
return refreshRetryAndClear
case meta.Category == errs.CategoryPolicy:
return refreshStopAndPreserve
case meta.Retryable:
return refreshRetryAndPreserve
default:
return refreshStopAndClear
accessToken := getStr(data, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("Token refresh returned no access_token")
}
}
func saveRefreshResponse(opts UATCallOptions, stored *StoredUAToken, response refreshResponse) (*StoredUAToken, error) {
now := time.Now().UnixMilli()
refreshToken := getStr(data, "refresh_token")
if refreshToken == "" {
refreshToken = stored.RefreshToken
}
expiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0)
refreshExpiresAt := stored.RefreshExpiresAt
if refreshExpiresIn > 0 {
refreshExpiresAt = now + int64(refreshExpiresIn)*1000
}
scope := getStr(data, "scope")
if scope == "" {
scope = stored.Scope
}
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
ExpiresAt: now + *response.ExpiresIn*1000,
RefreshExpiresAt: now + *response.RefreshTokenExpiresIn*1000,
Scope: response.Scope,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: now + int64(expiresIn)*1000,
RefreshExpiresAt: refreshExpiresAt,
Scope: scope,
GrantedAt: stored.GrantedAt,
}
current, saved, err := setStoredTokenIfCurrent(stored, updated)
if err != nil {
if err := SetStoredToken(updated); err != nil {
return nil, err
}
if !saved {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
}
return storedTokenAfterGenerationChange(current, opts.UserOpenId)
}
return updated, nil
}
func storedTokenAfterGenerationChange(current *StoredUAToken, userOpenId string) (*StoredUAToken, error) {
if current == nil {
return nil, nil
}
if TokenStatus(current) == "valid" {
return current, nil
}
return nil, errs.NewInternalError(errs.SubtypeStorage,
"stored refresh token changed while refreshing user %q", userOpenId).
WithRetryable().
WithHint("retry the command")
}
func ensureDirWritable(dir, tempPrefix string) error {
if dir == "" {
return nil
}
if err := vfs.MkdirAll(dir, 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to access refresh lock directory %q", dir).
WithCause(err).
WithHint("If running in a sandbox or read-only workspace, grant write access for this directory and retry.")
}
tmp, err := vfs.CreateTemp(dir, tempPrefix)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to create temporary file in refresh lock directory %q", dir).
WithCause(err).
WithHint("If running in a sandbox or read-only workspace, grant write access for this directory and retry.")
}
tmpName := tmp.Name()
closeErr := tmp.Close()
if removeErr := vfs.Remove(tmpName); removeErr != nil {
err := fmt.Errorf("%v", removeErr)
if closeErr != nil {
err = fmt.Errorf("%v; also failed to close temp file: %v", removeErr, closeErr)
}
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to clean up refresh lock write-check file %q", tmpName).
WithCause(err)
}
if closeErr != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to close refresh lock write-check file %q", tmpName).
WithCause(closeErr)
}
return nil
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package build
const Edition = "extended"
func Capabilities() []string {
return []string{"external-credential-platform"}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package build
const Edition = "standard"
func Capabilities() []string { return []string{} }

View File

@@ -23,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/util"
)
@@ -119,6 +120,7 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ctx = requestcontext.WithIdentity(ctx, as)
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
@@ -155,6 +157,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
ctx = requestcontext.WithIdentity(ctx, as)
cfg := buildConfig(opts)
// Resolve auth
@@ -212,6 +215,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"net/http"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
// RemoteFile is a service-returned file reference that has passed the active
// runtime's data-plane policy. Its URL is intentionally private so callers
// cannot construct a trusted reference without Validate.
type RemoteFile struct {
rawURL string
}
// URL returns the original service-provided URL verbatim.
func (f RemoteFile) URL() string { return f.rawURL }
// NewRequest constructs a request that remains bound to this validated file
// reference. Business shortcuts do not construct raw URL requests themselves.
func (f RemoteFile) NewRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
if f.rawURL == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"cannot build a request for an empty remote file reference")
}
req, err := http.NewRequestWithContext(ctx, method, f.rawURL, body)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"build remote file request: %v", err).WithCause(err)
}
return req, nil
}
// DirectRemoteFileValidator applies a caller-specific policy to direct-mode
// URLs. Proxy handles are validated by the managed data-plane policy instead.
type DirectRemoteFileValidator func(context.Context, string) error
// HTTPClientProvider resolves the runtime's configured HTTP client without
// exposing raw client construction to business shortcuts.
type HTTPClientProvider func() (*http.Client, error)
// RemoteFilePolicy is the source-neutral runtime contract for validating and
// routing service-returned file references.
type RemoteFilePolicy interface {
ValidateRemoteFile(rawURL string) error
UsesManagedFilePlane() bool
}
// RemoteFiles is the runtime boundary for service-returned file references.
// It keeps credential mode and edition routing out of business shortcuts.
// Every service-returned file byte transfer must pass through Validate,
// RemoteFile.NewRequest, and Do so the active data-plane policy is enforced.
type RemoteFiles struct {
policy RemoteFilePolicy
managedClient HTTPClientProvider
identity core.Identity
}
// NewRemoteFiles creates a file boundary for one resolved runtime identity.
func NewRemoteFiles(
policy RemoteFilePolicy,
managedClient HTTPClientProvider,
identity core.Identity,
) *RemoteFiles {
return &RemoteFiles{
policy: policy,
managedClient: managedClient,
identity: identity,
}
}
// Validate checks a known service-returned file URL and returns an opaque,
// typed reference. A directValidator, when supplied, is applied only to the
// ordinary/direct data plane; proxy handles use the configured proxy policy.
func (r *RemoteFiles) Validate(
ctx context.Context,
rawURL string,
directValidator ...DirectRemoteFileValidator,
) (RemoteFile, error) {
if r == nil {
return RemoteFile{}, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if r.policy != nil {
if err := r.policy.ValidateRemoteFile(rawURL); err != nil {
return RemoteFile{}, err
}
}
if !usesManagedFilePlane(r.policy) && len(directValidator) > 0 && directValidator[0] != nil {
if err := directValidator[0](ctx, rawURL); err != nil {
return RemoteFile{}, err
}
}
return RemoteFile{rawURL: rawURL}, nil
}
// RequirePortableURL rejects commands that would return a managed file handle
// for use outside this CLI. The check is capability-based, so callers do not
// branch on a credential mode.
func (r *RemoteFiles) RequirePortableURL(param string) error {
if r == nil || !usesManagedFilePlane(r.policy) {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%s is unavailable because this credential source requires CLI-managed file routing", param).
WithParam(param).
WithHint("omit %s and let lark-cli transfer the file", param)
}
// Do executes a request for a validated remote file. Ordinary/direct runtimes
// use directClient unchanged; a nil directClient gets the same isolated
// DefaultTransport client historically used by presigned Apps transfers.
// Managed runtimes select the proxy-aware client and copy the caller's
// timeout/redirect policy so existing transfer semantics remain intact.
func (r *RemoteFiles) Do(req *http.Request, file RemoteFile, directClient *http.Client) (*http.Response, error) {
if r == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if req == nil || req.URL == nil || file.rawURL == "" || req.URL.String() != file.rawURL {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"remote file request does not match its validated reference")
}
if directClient == nil {
directClient = &http.Client{Transport: http.DefaultTransport}
}
selected := directClient
managedPlane := usesManagedFilePlane(r.policy)
if managedPlane {
if r.managedClient == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime has no HTTP client")
}
managed, err := r.managedClient()
if err != nil {
return nil, err
}
if managed == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime returned no HTTP client")
}
if directClient != nil && managed != directClient {
cloned := *managed
cloned.Timeout = directClient.Timeout
if directClient.CheckRedirect != nil {
cloned.CheckRedirect = directClient.CheckRedirect
}
selected = &cloned
} else {
selected = managed
}
}
if !managedPlane {
return selected.Do(req)
}
requestCtx := requestcontext.WithIdentity(req.Context(), r.identity)
return selected.Do(req.Clone(requestCtx))
}
func usesManagedFilePlane(policy RemoteFilePolicy) bool {
return policy != nil && policy.UsesManagedFilePlane()
}

View File

@@ -0,0 +1,317 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
const externalCredentialImplementation = "github.com/larksuite/cli/internal/externalcredential"
// These functions own protocols that are explicitly outside the
// service-returned file plane: user-supplied external downloads and MCP.
// Adding a boundary is an architectural decision; //nolint alone must never
// make a new raw HTTP path possible.
var shortcutRawHTTPBoundaries = map[string]string{
"common/mcp_client.go:DoMCPCall": "MCP protocol client",
"doc/doc_resource_cover.go:downloadDocCoverURL": "validated user-supplied cover URL",
"doc/doc_resource_cover.go:newDocCoverHTTPClient": "guarded external-download client",
"doc/doc_resource_cover.go:cloneDocCoverTransport": "guarded external-download transport",
"im/helpers.go:startURLDownload": "validated user-supplied media URL",
}
func TestCoreDoesNotOwnExternalCredentialProductModel(t *testing.T) {
checkCoreDirectoryIsProductNeutral(t, filepath.Join("..", "core"))
}
func TestRuntimeFrameworkDoesNotImportExternalCredentialImplementation(t *testing.T) {
for _, dir := range []string{
".",
filepath.Join("..", "cmdutil"),
filepath.Join("..", "credential"),
} {
checkDirectoryDoesNotImportExternalCredential(t, dir)
}
}
func checkCoreDirectoryIsProductNeutral(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
forbidden := []string{
"externalcredential",
"external-credential",
"credential_proxy",
"platform_proxy",
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkCoreDirectoryIsProductNeutral(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
normalizedSource := strings.ToLower(string(src))
for _, productSymbol := range forbidden {
if strings.Contains(normalizedSource, strings.ToLower(productSymbol)) {
t.Errorf("%s contains external credential product symbol %q; keep the core profile model product-neutral", path, productSymbol)
}
}
}
}
func TestBusinessShortcutsUseRuntimeCredentialBoundaries(t *testing.T) {
checkBusinessShortcutDirectory(t, filepath.Join("..", "..", "shortcuts"))
}
func TestShortcutRawHTTPConstructionIsConfinedToExplicitBoundaries(t *testing.T) {
root := filepath.Join("..", "..", "shortcuts")
checkShortcutRawHTTPDirectory(t, root, root)
}
func TestRawHTTPConstructionGuardRecognizesAliasedBypasses(t *testing.T) {
const source = `package fixture
import nethttp "net/http"
func bypass() {
_ = &nethttp.Client{}
_, _ = nethttp.NewRequest(nethttp.MethodGet, "https://files.example", nil)
_ = nethttp.DefaultClient
}`
file, err := parser.ParseFile(token.NewFileSet(), "fixture.go", source, 0)
if err != nil {
t.Fatal(err)
}
uses := rawHTTPConstructions(file, map[string]struct{}{"nethttp": {}})
joined := strings.Join(uses, ",")
for _, want := range []string{"Client literal", "NewRequest", "DefaultClient"} {
if !strings.Contains(joined, want) {
t.Fatalf("raw HTTP uses = %q, want %q", joined, want)
}
}
}
func checkDirectoryDoesNotImportExternalCredential(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkDirectoryDoesNotImportExternalCredential(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; inject a source-neutral runtime boundary", path)
}
}
}
}
func checkBusinessShortcutDirectory(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if entry.Name() != "common" {
checkBusinessShortcutDirectory(t, path)
}
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; use a source-neutral runtime capability boundary", path)
}
}
ast.Inspect(file, func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if ok && selector.Sel.Name == "ExternalCredential" {
t.Errorf("%s selects ExternalCredential directly; use a runtime capability boundary", path)
}
return true
})
}
}
func checkShortcutRawHTTPDirectory(t *testing.T, root, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkShortcutRawHTTPDirectory(t, root, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
httpAliases := make(map[string]struct{})
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath != "net/http" {
continue
}
alias := "http"
if spec.Name != nil {
alias = spec.Name.Name
}
if alias == "." {
t.Errorf("%s dot-imports net/http; raw HTTP boundaries must remain statically auditable", path)
continue
}
if alias != "_" {
httpAliases[alias] = struct{}{}
}
}
if len(httpAliases) == 0 {
continue
}
relativePath, err := filepath.Rel(root, path)
if err != nil {
t.Fatalf("relative shortcut path for %s: %v", path, err)
}
relativePath = filepath.ToSlash(relativePath)
for _, decl := range file.Decls {
fn, isFunc := decl.(*ast.FuncDecl)
uses := rawHTTPConstructions(decl, httpAliases)
if len(uses) == 0 {
continue
}
if !isFunc {
t.Errorf("%s constructs raw net/http at package scope (%s); route service-returned file bytes through RuntimeContext.RemoteFiles",
path, strings.Join(uses, ", "))
continue
}
boundary := relativePath + ":" + fn.Name.Name
if _, allowed := shortcutRawHTTPBoundaries[boundary]; allowed {
continue
}
t.Errorf("%s function %s constructs raw net/http (%s); route service-returned file bytes through RuntimeContext.RemoteFiles or declare a reviewed non-file-plane boundary",
path, fn.Name.Name, strings.Join(uses, ", "))
}
}
}
func rawHTTPConstructions(node ast.Node, aliases map[string]struct{}) []string {
seen := make(map[string]struct{})
var uses []string
add := func(name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
uses = append(uses, name)
}
isHTTPSelector := func(selector *ast.SelectorExpr) bool {
ident, ok := selector.X.(*ast.Ident)
if !ok {
return false
}
_, ok = aliases[ident.Name]
return ok
}
ast.Inspect(node, func(current ast.Node) bool {
switch typed := current.(type) {
case *ast.CallExpr:
selector, ok := typed.Fun.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "NewRequest", "NewRequestWithContext", "Get", "Post", "PostForm", "Head", "Serve", "ListenAndServe":
add(selector.Sel.Name)
}
case *ast.CompositeLit:
selector, ok := typed.Type.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "Client", "Request", "Transport":
add(selector.Sel.Name + " literal")
}
case *ast.SelectorExpr:
if !isHTTPSelector(typed) {
break
}
switch typed.Sel.Name {
case "DefaultClient", "DefaultTransport":
add(typed.Sel.Name)
}
}
return true
})
return uses
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
type testRemoteFilePolicy struct {
allowed string
managed bool
}
func (p testRemoteFilePolicy) ValidateRemoteFile(rawURL string) error {
if p.allowed != "" && rawURL != p.allowed {
return errors.New("remote file rejected")
}
return nil
}
func (p testRemoteFilePolicy) UsesManagedFilePlane() bool { return p.managed }
func TestRemoteFilesDirectPreservesCallerClientAndPolicy(t *testing.T) {
var managedCalls, directCalls, validationCalls int
direct := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
directCalls++
if got := requestcontext.Identity(req.Context()); got != "" {
t.Fatalf("direct request context was changed: identity = %q", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
files := NewRemoteFiles(nil, func() (*http.Client, error) {
managedCalls++
return nil, errors.New("must not be called")
}, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/object?signature=x",
func(_ context.Context, rawURL string) error {
validationCalls++
if rawURL != "https://files.example/object?signature=x" {
t.Fatalf("validator URL = %q", rawURL)
}
return nil
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if directCalls != 1 || managedCalls != 0 || validationCalls != 1 {
t.Fatalf("calls direct=%d managed=%d validation=%d", directCalls, managedCalls, validationCalls)
}
}
func TestRemoteFilesManagedValidatesAndRoutesOpaqueHandle(t *testing.T) {
const opaqueURL = "https://proxy.example/lark-cli/v1/files/opaque_1"
policy := testRemoteFilePolicy{allowed: opaqueURL, managed: true}
var managedCalls, directCalls, directValidationCalls int
managed := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
managedCalls++
if got := requestcontext.Identity(req.Context()); got != core.AsBot {
t.Fatalf("request identity = %q, want bot", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
direct := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
directCalls++
return nil, errors.New("must not be called")
})}
files := NewRemoteFiles(policy, func() (*http.Client, error) { return managed, nil }, core.AsBot)
if _, err := files.Validate(context.Background(), "https://objects.example/raw?signature=secret"); err == nil {
t.Fatal("raw object URL accepted in managed mode")
}
file, err := files.Validate(context.Background(), opaqueURL,
func(context.Context, string) error {
directValidationCalls++
return errors.New("must not be called")
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if managedCalls != 1 || directCalls != 0 || directValidationCalls != 0 {
t.Fatalf("calls managed=%d direct=%d validation=%d", managedCalls, directCalls, directValidationCalls)
}
}
func TestRemoteFilesDoRejectsReferenceMismatch(t *testing.T) {
files := NewRemoteFiles(nil, nil, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/one")
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "https://files.example/two", nil)
if _, err := files.Do(req, file, http.DefaultClient); err == nil {
t.Fatal("mismatched request and validated file accepted")
}
}
func TestRemoteFilesRequirePortableURL(t *testing.T) {
files := NewRemoteFiles(testRemoteFilePolicy{managed: true}, nil, core.AsBot)
err := files.RequirePortableURL("--url-only")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Param != "--url-only" {
t.Fatalf("error = subtype %q param %q", validationErr.Subtype, validationErr.Param)
}
}
func TestCallAPIPreservesURLsInArbitraryJSON(t *testing.T) {
const rawURL = "https://objects.example/raw?signature=opaque"
apiClient, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
resp := jsonResponse(map[string]any{
"code": 0,
"data": map[string]any{
"download_url": rawURL,
"nested": []any{map[string]any{"url": rawURL}},
},
})
resp.Request = req
return resp, nil
}))
result, err := apiClient.CallAPI(context.Background(), RawApiRequest{
Method: http.MethodGet,
URL: "/open-apis/example/v1/raw",
As: core.AsBot,
})
if err != nil {
t.Fatal(err)
}
data := result.(map[string]any)["data"].(map[string]any)
if got := data["download_url"]; got != rawURL {
t.Fatalf("download_url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
nested := data["nested"].([]any)[0].(map[string]any)
if got := nested["url"]; got != rawURL {
t.Fatalf("nested url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/runtimeplan"
)
// Factory holds shared dependencies injected into every command.
@@ -43,6 +44,8 @@ type Factory struct {
Credential *credential.CredentialProvider
runtimePlan *runtimeplan.Plan
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
@@ -57,6 +60,34 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
return f.FileIOProvider.ResolveFileIO(ctx)
}
// NewRemoteFiles returns the invocation's file-transfer boundary without
// exposing credential source, mode, or edition to business shortcuts.
func (f *Factory) NewRemoteFiles(identity core.Identity) *client.RemoteFiles {
if f == nil {
return client.NewRemoteFiles(nil, nil, identity)
}
return client.NewRemoteFiles(runtimeplan.Ensure(f.runtimePlan), f.HttpClient, identity)
}
// RuntimeDescription returns sanitized diagnostics for the active plan.
func (f *Factory) RuntimeDescription() runtimeplan.Description {
if f == nil {
return runtimeplan.Description{}
}
return runtimeplan.Ensure(f.runtimePlan).Describe()
}
// RuntimeStartupError reports whether invocation bootstrap failed before an
// effective credential/runtime configuration could be selected. It exposes
// only the source-neutral plan contract so diagnostic commands do not need to
// know which edition or credential product produced the failure.
func (f *Factory) RuntimeStartupError() error {
if f == nil {
return nil
}
return runtimeplan.Ensure(f.runtimePlan).StartupError()
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on credential hints.
@@ -210,26 +241,3 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
Credential: f.Credential,
}, nil
}
// RequireBuiltinCredentialProvider returns a typed validation error when an
// extension provider is actively managing credentials. Intended for use as
// PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%q is not supported: credentials are provided externally and do not support interactive management", command).
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
}

View File

@@ -23,11 +23,17 @@ import (
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimeplan"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
var (
initRegistryWithBrand = registry.InitWithBrand
initEmbeddedRegistryWithBrand = registry.InitEmbeddedWithBrand
)
// NewDefault creates a production Factory with cached closures.
// Initialization follows a credential-first order:
//
@@ -36,19 +42,39 @@ import (
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Preserve the established standalone Factory behavior. Product-specific
// runtime selection belongs to the CLI composition root, which calls
// NewDefaultWithRuntimePlan with one immutable startup snapshot.
core.SetCurrentWorkspace(core.DetectWorkspaceFromEnv(os.Getenv))
return newDefaultWithRuntimePlan(streams, inv, nil, runtimeplan.Default(), false)
}
// NewDefaultWithRuntimePlan creates a production Factory from the same
// immutable Profile snapshot and source-neutral plan used by startup routing.
func NewDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
) *Factory {
return newDefaultWithRuntimePlan(streams, inv, profileConfig, plan, true)
}
func newDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
useProfileSnapshot bool,
) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
runtimePlan: runtimeplan.Ensure(plan),
}
// Workspace detection: determines which config subtree to use.
// Must run before any config or credential load, since those paths are
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
@@ -56,6 +82,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
if profileConfig != nil {
workspaceConfig = core.NewConfigSnapshotFrom(profileConfig)
}
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
@@ -63,10 +92,13 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
RuntimePlan: f.runtimePlan,
ProfileConfigSnapshot: profileConfig,
UseProfileSnapshot: useProfileSnapshot,
})
// Phase 3: Runtime config contains resolved account data only.
@@ -76,7 +108,13 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return nil, err
}
cfg := acct.ToCliConfig()
registry.InitWithBrand(cfg.Brand)
if f.runtimePlan.AllowsRemoteMetadata() {
initRegistryWithBrand(cfg.Brand)
} else {
// Defense in depth for callers that construct a Factory directly
// instead of going through cmd/build's composition root.
initEmbeddedRegistryWithBrand(cfg.Brand)
}
return cfg, nil
})
@@ -120,6 +158,13 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
var err error
rt, err = applyRuntimePlan(f, rt)
if err != nil {
return nil, err
}
// Risk control remains the final trusted header boundary before either
// the ordinary network transport or the managed proxy data plane.
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
@@ -150,9 +195,14 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
sdkBase, err = applyRuntimePlan(f, sdkBase)
if err != nil {
return nil, err
}
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
// collection. A managed runtime applies its data-plane policy after this
// boundary so trusted signals remain associated with the original request.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
@@ -170,20 +220,51 @@ func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
sdkTransport = wrapWithExtension(sdkTransport)
return sdkTransport
}
func applyRuntimePlan(f *Factory, base http.RoundTripper) (http.RoundTripper, error) {
if f == nil {
return base, nil
}
return runtimeplan.Ensure(f.runtimePlan).Wrap(base)
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
RuntimePlan *runtimeplan.Plan
ProfileConfigSnapshot *core.MultiAppConfig
UseProfileSnapshot bool
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
plan := runtimeplan.Ensure(deps.RuntimePlan)
providers := extcred.Providers()
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
localAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
if deps.UseProfileSnapshot {
localAcct = credential.NewDefaultAccountProviderFromSnapshot(deps.Keychain, deps.Profile, deps.ProfileConfigSnapshot)
}
localToken := credential.NewDefaultTokenProvider(localAcct, deps.HttpClient, deps.ErrOut)
var defaultAcct credential.DefaultAccountResolver = localAcct
var defaultToken credential.DefaultTokenResolver = localToken
if startupErr := plan.StartupError(); startupErr != nil {
providers = []extcred.Provider{&runtimePlanErrorProvider{err: startupErr}}
defaultAcct = nil
defaultToken = nil
} else if provider, replace := plan.CredentialProvider(); provider != nil {
if replace {
providers = []extcred.Provider{provider}
defaultAcct = nil
defaultToken = nil
} else {
providers = append([]extcred.Provider{provider}, providers...)
}
}
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
// happens before the command runs, so any plain-text warning written
// to stderr would break the JSON envelope contract that AI agents
@@ -192,3 +273,15 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}
type runtimePlanErrorProvider struct{ err error }
func (p *runtimePlanErrorProvider) Name() string { return "runtime-policy" }
func (p *runtimePlanErrorProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, p.err
}
func (p *runtimePlanErrorProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, p.err
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
@@ -14,6 +15,9 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -213,3 +217,63 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
func TestRuntimePlanMetadataPolicyIsEnforcedByFactory(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
originalRemote := initRegistryWithBrand
originalEmbedded := initEmbeddedRegistryWithBrand
t.Cleanup(func() {
initRegistryWithBrand = originalRemote
initEmbeddedRegistryWithBrand = originalEmbedded
})
var remoteCalls, embeddedCalls int
initRegistryWithBrand = func(core.LarkBrand) { remoteCalls++ }
initEmbeddedRegistryWithBrand = func(core.LarkBrand) { embeddedCalls++ }
profile := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}}}
plan := runtimeplan.New(runtimeplan.Options{Metadata: runtimeplan.MetadataEmbeddedOnly})
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, profile, plan)
if _, err := f.Config(); err != nil {
t.Fatalf("Config() error = %v", err)
}
if remoteCalls != 0 || embeddedCalls != 1 {
t.Fatalf("registry init calls = remote:%d embedded:%d, want remote:0 embedded:1", remoteCalls, embeddedCalls)
}
}
func TestCLICompositionPreservesEnvironmentCredentialsWhenProfileIsUnreadable(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configDir, "missing-external-credential.json"))
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := vfs.MkdirAll(core.GetConfigPath(), 0o700); err != nil {
t.Fatal(err)
}
startup := runtimebootstrap.Resolve("")
if err := startup.Plan.StartupError(); err != nil {
t.Fatalf("runtime bootstrap changed legacy environment fallback: %v", err)
}
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, startup.ProfileConfig, startup.Plan)
account, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdutil
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"
_ "github.com/larksuite/cli/extension/credential/env"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/externalcredential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimebootstrap"
internaltransport "github.com/larksuite/cli/internal/transport"
)
func newFactoryFromRuntimeBootstrap(streams *IOStreams, inv InvocationContext) *Factory {
startup := runtimebootstrap.Resolve(inv.Profile)
return NewDefaultWithRuntimePlan(streams, inv, startup.ProfileConfig, startup.Plan)
}
func writePlatformProxyConfiguration(t *testing.T, config *core.MultiAppConfig, appID string) {
t.Helper()
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
systemPath := configDir + "/external-credential.json"
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
system := externalcredential.Config{
Version: 1, Mode: externalcredential.ModePlatformProxy,
RemoteEndpoint: "https://credentials.example.com",
Applications: []externalcredential.Application{{Brand: core.BrandFeishu, AppID: appID}},
}
data, err := json.Marshal(system)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemPath, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestFactoryInstallsExternalProxyTransport(t *testing.T) {
t.Setenv(internaltransport.EnvNoProxy, "")
previousTransport := http.DefaultTransport
var observed *http.Request
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
observed = req.Clone(req.Context())
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "sandbox", AppId: "cli_test", Brand: core.BrandFeishu, Lang: i18n.LangJaJP, Users: []core.AppUser{},
}}}
writePlatformProxyConfiguration(t, config, "cli_test")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
resolved, err := factory.Config()
if err != nil {
t.Fatal(err)
}
if resolved.Lang != i18n.LangJaJP {
t.Fatalf("resolved Lang = %q, want Profile Lang %q", resolved.Lang, i18n.LangJaJP)
}
client, err := factory.HttpClient()
if err != nil {
t.Fatal(err)
}
ctx := requestcontext.WithIdentity(context.Background(), core.AsUser)
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://open.feishu.cn/open-apis/test/v1/ping", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if observed == nil ||
observed.URL.String() != "https://credentials.example.com/lark-cli/v1/openapi/open-apis/test/v1/ping" ||
observed.Header.Get(externalcredential.HeaderAppID) != "cli_test" ||
observed.Header.Get(externalcredential.HeaderIdentity) != "user" ||
observed.Header.Get(riskcontrol.HeaderOSType) == "" {
t.Fatalf("managed data-plane request = %#v", observed)
}
}
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsMalformed(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := os.WriteFile(core.GetConfigPath(), []byte(`{"apps":[`), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsUnreadable(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := os.Mkdir(core.GetConfigPath(), 0700); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}
func TestFactoryRejectsMisspelledExternalCredentialInsteadOfFallingBack(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredentials":{"mode":"proxy"}}]}`
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected misspelled external credential profile to fail closed")
}
}
func TestFactoryRejectsNullExternalCredentialInsteadOfFallingBack(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredential":null}]}`
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected null external credential profile to fail closed")
}
}
func TestFactoryRejectsMissingSelectedExternalCredentialProfile(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
config := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "sandbox", AppId: "cli_external", Brand: core.BrandFeishu, Users: []core.AppUser{},
}},
}
writePlatformProxyConfiguration(t, config, "cli_external")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected missing selected external credential profile to fail closed")
}
}
func TestFactoryUsesOneProfileSnapshotForCredentialAndTransport(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
systemPath := configDir + "/external-credential.json"
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
for _, name := range []string{
envvars.CliAppID, envvars.CliAppSecret, envvars.CliUserAccessToken,
envvars.CliTenantAccessToken, envvars.CliAuthProxy, envvars.CliProxyKey,
envvars.CliProxyEnable, envvars.CliProxyAddress, envvars.CliCAPath,
} {
t.Setenv(name, "")
}
core.SetCurrentWorkspace(core.WorkspaceLocal)
initial := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "initial", AppId: "cli_initial", AppSecret: core.PlainSecret("initial-secret"),
Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
if err := core.SaveMultiAppConfig(initial); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
replacement := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "replacement", AppId: "cli_proxy", Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
if err := core.SaveMultiAppConfig(replacement); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemPath, []byte(`{"version":1,"mode":"platform_proxy","remoteEndpoint":"https://credentials.example.com","applications":[{"brand":"feishu","appId":"cli_proxy"}]}`), 0o600); err != nil {
t.Fatal(err)
}
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if account.AppID != "cli_initial" {
t.Fatalf("account AppID = %q, want immutable snapshot cli_initial", account.AppID)
}
client, err := factory.HttpClient()
if err != nil {
t.Fatal(err)
}
if _, ok := client.Transport.(*externalcredential.Transport); ok {
t.Fatalf("transport = %T, should match initial non-proxy profile", client.Transport)
}
}
func TestFactoryRejectsProxyModeWithTransportExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
writePlatformProxyConfiguration(t, config, "cli_test")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.HttpClient(); err == nil {
t.Fatal("expected proxy mode and transport extension to be rejected")
}
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
@@ -413,13 +414,13 @@ func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (
return nil, nil
}
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
func TestRequireRuntimeCapabilities_BlocksProviderOwnedCredentials(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -439,25 +440,44 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
}
}
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
func TestRequireRuntimeCapabilities_DefaultCommandLabel(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireRuntimeCapabilities(context.Background(), "", runtimeplan.CapabilityLocalCredentialManagement)
if err == nil {
t.Fatal("expected error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed error", err)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestRequireRuntimeCapabilities_AllowsLocalCredentialProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
func TestRequireRuntimeCapabilities_AllowsNilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
func TestRequireRuntimeCapabilities_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
@@ -465,7 +485,7 @@ func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/runtimeplan"
)
const (
runtimeCapabilitiesAnnotation = "lark:runtimeCapabilities"
noRuntimeCapabilities = "-"
)
// SetRuntimeCapabilities declares the runtime surfaces required by a command.
// A zero-capability declaration explicitly overrides a parent default.
func SetRuntimeCapabilities(cmd *cobra.Command, capabilities ...runtimeplan.Capability) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
if len(capabilities) == 0 {
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
return
}
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if capability != "" {
values = append(values, string(capability))
}
}
if len(values) == 0 {
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
return
}
cmd.Annotations[runtimeCapabilitiesAnnotation] = strings.Join(values, ",")
}
// GetRuntimeCapabilities resolves the nearest explicit declaration from the
// leaf command toward its parents.
func GetRuntimeCapabilities(cmd *cobra.Command) []runtimeplan.Capability {
for current := cmd; current != nil; current = current.Parent() {
raw, ok := current.Annotations[runtimeCapabilitiesAnnotation]
if !ok {
continue
}
if raw == "" || raw == noRuntimeCapabilities {
return nil
}
parts := strings.Split(raw, ",")
out := make([]runtimeplan.Capability, 0, len(parts))
for _, part := range parts {
if capability := strings.TrimSpace(part); capability != "" {
out = append(out, runtimeplan.Capability(capability))
}
}
return out
}
return nil
}
// RequireRuntimeCapabilities applies the invocation plan and the selected
// credential source to a command's source-neutral capability declaration.
func (f *Factory) RequireRuntimeCapabilities(
ctx context.Context,
command string,
capabilities ...runtimeplan.Capability,
) error {
if f == nil {
return nil
}
plan := runtimeplan.Ensure(f.runtimePlan)
for _, capability := range capabilities {
if err := plan.Require(capability); err != nil {
return err
}
if capability != runtimeplan.CapabilityLocalCredentialManagement || f.Credential == nil {
continue
}
providerName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if providerName == "" {
continue
}
if command == "" {
command = "credential management"
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%q cannot manage credentials owned by provider %q", command, providerName).
WithHint("manage authorization through the active credential provider, or use a local Profile credential source")
}
return nil
}
// RequireCommandRuntimeCapabilities checks the declaration resolved for cmd.
func (f *Factory) RequireCommandRuntimeCapabilities(ctx context.Context, cmd *cobra.Command) error {
command := "credential management"
if cmd != nil {
command = cmd.CommandPath()
}
return f.RequireRuntimeCapabilities(ctx, command, GetRuntimeCapabilities(cmd)...)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
func TestRuntimeCapabilitiesUseNearestDeclaration(t *testing.T) {
parent := &cobra.Command{Use: "auth"}
SetRuntimeCapabilities(parent, runtimeplan.CapabilityLocalCredentialManagement)
inherited := &cobra.Command{Use: "login"}
parent.AddCommand(inherited)
got := GetRuntimeCapabilities(inherited)
if len(got) != 1 || got[0] != runtimeplan.CapabilityLocalCredentialManagement {
t.Fatalf("inherited capabilities = %v", got)
}
diagnostic := &cobra.Command{Use: "status"}
SetRuntimeCapabilities(diagnostic)
parent.AddCommand(diagnostic)
if got := GetRuntimeCapabilities(diagnostic); len(got) != 0 {
t.Fatalf("explicit empty capabilities = %v", got)
}
}
func TestRequireRuntimeCapabilitiesUsesPlan(t *testing.T) {
denied := errors.New("events denied")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil, plan)
err := f.RequireRuntimeCapabilities(context.Background(), "event consume", runtimeplan.CapabilityRealtimeEvents)
if !errors.Is(err, denied) {
t.Fatalf("error = %v, want plan denial", err)
}
}
func TestRequireRuntimeCapabilitiesKeepsPurelyLocalCommandsAvailable(t *testing.T) {
startupErr := errors.New("managed runtime bootstrap failed")
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly))
if err := f.RequireRuntimeCapabilities(context.Background(), "local recovery"); err != nil {
t.Fatalf("capability-free local command = %v, want available", err)
}
}
func TestRequireRuntimeCapabilitiesBlocksProviderOwnedCredentials(t *testing.T) {
provider := &runtimeCapabilityProvider{}
f, _, _, _ := TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider}, nil, nil, nil,
)
err := f.RequireRuntimeCapabilities(
context.Background(),
"auth login",
runtimeplan.CapabilityLocalCredentialManagement,
)
if err == nil {
t.Fatal("expected provider-owned credentials to reject local management")
}
if err := f.RequireRuntimeCapabilities(
context.Background(),
"profile use",
runtimeplan.CapabilityLocalProfileMutation,
); err != nil {
t.Fatalf("generic provider unexpectedly blocked Standard Profile mutation: %v", err)
}
}
type runtimeCapabilityProvider struct{}
func (*runtimeCapabilityProvider) Name() string { return "test-provider" }
func (*runtimeCapabilityProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_test"}, nil
}
func (*runtimeCapabilityProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
@@ -76,6 +77,29 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
return f, stdoutBuf, stderrBuf, reg
}
// TestFactoryWithRuntimePlan creates a TestFactory with an explicit
// source-neutral runtime policy.
func TestFactoryWithRuntimePlan(
t *testing.T,
config *core.CliConfig,
plan *runtimeplan.Plan,
) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
f, out, errOut, registry := TestFactory(t, config)
f.runtimePlan = runtimeplan.Ensure(plan)
return f, out, errOut, registry
}
// TestSetRuntimePlan replaces a Factory's runtime policy in tests that need to
// preserve an existing HTTP mock registry or other custom wiring.
func TestSetRuntimePlan(t *testing.T, f *Factory, plan *runtimeplan.Plan) {
t.Helper()
if f == nil {
t.Fatal("cannot install a runtime plan on a nil Factory")
}
f.runtimePlan = runtimeplan.Ensure(plan)
}
type testDefaultAcct struct {
config *core.CliConfig
}

View File

@@ -21,6 +21,14 @@ func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
// NewConfigSnapshotFrom creates a snapshot from configuration that was
// already captured by another invocation-start boundary.
func NewConfigSnapshotFrom(config *MultiAppConfig) *ConfigSnapshot {
return newConfigSnapshot(func() (*MultiAppConfig, error) {
return config, nil
})
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}

View File

@@ -38,6 +38,19 @@ func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
}
}
func TestNewConfigSnapshotFromPreservesCapturedConfig(t *testing.T) {
want := &MultiAppConfig{CurrentApp: "captured"}
snapshot := NewConfigSnapshotFrom(want)
got, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if got != want || got.CurrentApp != "captured" {
t.Fatalf("MultiAppConfig() = %#v, want captured pointer %#v", got, want)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")

View File

@@ -14,6 +14,7 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
// DefaultAccountResolver is implemented by the default account provider.
@@ -26,6 +27,34 @@ type DefaultTokenResolver interface {
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
}
type userInfoEnrichmentSkipper interface {
SkipUserInfoEnrichment() bool
}
// ProviderCapabilities describes source-neutral credential behavior used by
// inspection and account resolution. It carries no provider configuration.
type ProviderCapabilities struct {
SkipUserInfoEnrichment bool
ProvidesOnDemandAuth bool
CanInspectScopes bool
}
type providerCapabilitiesSource interface {
CredentialCapabilities() ProviderCapabilities
}
// ProviderAccountMetadata carries source-neutral Profile preferences that are
// not part of the public credential extension account contract. Internal
// runtime providers can expose them without coupling core configuration to a
// concrete credential product.
type ProviderAccountMetadata struct {
Lang i18n.Lang
}
type providerAccountMetadataSource interface {
CredentialAccountMetadata() ProviderAccountMetadata
}
var (
getStoredToken = auth.GetStoredToken
getStoredTokenStatus = auth.TokenStatus
@@ -136,10 +165,12 @@ type CredentialProvider struct {
httpClient func() (*http.Client, error)
warnOut io.Writer
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
selectedProvider extcred.Provider
selectedCaps ProviderCapabilities
hintOnce sync.Once
hint *IdentityHint
@@ -176,36 +207,61 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
// A provider error stops the chain, so remember that source as the
// immutable selection even though account resolution failed.
p.selectedSource = extensionTokenSource{provider: prov}
p.selectedProvider = prov
p.selectedCaps = credentialProviderCapabilities(prov)
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
if source, ok := prov.(providerAccountMetadataSource); ok {
metadata := source.CredentialAccountMetadata()
internal.Lang = metadata.Lang
}
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
capabilities := credentialProviderCapabilities(prov)
skipEnrichment := capabilities.SkipUserInfoEnrichment
if skipper, ok := prov.(userInfoEnrichmentSkipper); ok {
skipEnrichment = skipper.SkipUserInfoEnrichment()
}
if !skipEnrichment {
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
p.selectedSource = source
p.selectedProvider = prov
p.selectedCaps = capabilities
return internal, nil
}
}
if p.defaultAcct != nil {
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
return nil, core.NotConfiguredError()
}
func credentialProviderCapabilities(provider extcred.Provider) ProviderCapabilities {
if source, ok := provider.(providerCapabilitiesSource); ok {
return source.CredentialCapabilities()
}
return ProviderCapabilities{CanInspectScopes: true}
}
// enrichUserInfo resolves user identity when extension provides a UAT.
// If UAT is available, user_info API call is mandatory (security: verify token validity).
// If no UAT from extension, falls back to provider-supplied OpenID.

View File

@@ -120,7 +120,7 @@ func TestCredentialProvider_TokenFromExtension(t *testing.T) {
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
token: &extcred.Token{Value: "ext_tok", Scopes: "im:message", Source: "env"},
}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
@@ -128,8 +128,28 @@ func TestCredentialProvider_TokenFromExtension(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
if result.Token != "ext_tok" || result.Scopes != "im:message" {
t.Errorf("extension token = %#v, want token and scopes preserved", result)
}
}
func TestCredentialProvider_ResolveTokenTreatsEmptyExtensionTokenAsMalformed(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{},
}},
nil, nil, nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
var malformedErr *MalformedTokenResultError
if !errors.As(err, &malformedErr) {
t.Fatalf("ResolveToken() error = %T %v, want *MalformedTokenResultError", err, err)
}
if malformedErr.Source != "env" || malformedErr.Type != TokenTypeUAT || malformedErr.Reason != "empty token" {
t.Fatalf("malformed token error = %+v, want env/uat/empty token", malformedErr)
}
}

View File

@@ -61,8 +61,21 @@ func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) e
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain func() keychain.KeychainAccess
profile string
keychain func() keychain.KeychainAccess
profile string
snapshot *core.MultiAppConfig
useSnapshot bool
}
// NewDefaultAccountProviderFromSnapshot creates the production provider for a
// Factory. A nil snapshot means no usable config existed when the Factory was
// created; the provider does not re-read a file that may have changed since
// transport wiring was selected.
func NewDefaultAccountProviderFromSnapshot(kc func() keychain.KeychainAccess, profile string, snapshot *core.MultiAppConfig) *DefaultAccountProvider {
provider := NewDefaultAccountProvider(kc, profile)
provider.snapshot = snapshot
provider.useSnapshot = true
return provider
}
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
@@ -73,9 +86,16 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
}
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
// Load config once — used for both credentials and strict mode.
multi, err := core.LoadMultiAppConfig()
if err != nil {
// Use one config for both credentials and strict mode.
multi := p.snapshot
if !p.useSnapshot {
var err error
multi, err = core.LoadMultiAppConfig()
if err != nil {
return nil, core.NotConfiguredError()
}
}
if multi == nil {
return nil, core.NotConfiguredError()
}

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"strings"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
)
// ScopeState describes whether a credential source can authoritatively report
// the scopes granted to a token. It deliberately distinguishes an omitted
// answer from a source that does not support scope inspection.
type ScopeState string
const (
ScopeKnown ScopeState = "known"
ScopeUnknown ScopeState = "unknown"
ScopeUnsupported ScopeState = "unsupported"
)
// TokenInspectionStatus is the local, non-secret state of a token or identity.
type TokenInspectionStatus string
const (
TokenInspectionReady TokenInspectionStatus = "ready"
TokenInspectionNeedsRefresh TokenInspectionStatus = "needs_refresh"
TokenInspectionExpired TokenInspectionStatus = "expired"
TokenInspectionMissing TokenInspectionStatus = "missing"
TokenInspectionNotLoggedIn TokenInspectionStatus = "not_logged_in"
TokenInspectionNotSupported TokenInspectionStatus = "not_supported"
TokenInspectionAvailableLive TokenInspectionStatus = "available_on_demand"
)
// SourceInspection is a sanitized description of the selected credential
// source. It never contains an app secret or any resolved credential value.
type SourceInspection struct {
Name string
Managed bool
AppID string
Brand core.LarkBrand
DefaultAs core.Identity
ProfileName string
UserOpenID string
UserName string
SupportedIdentities uint8
ProvidesOnDemandAuth bool
CanInspectScopes bool
}
// TokenInspectionRequest controls a non-secret token inspection.
type TokenInspectionRequest struct {
TokenSpec
IncludeScopes bool
}
// TokenInspection contains only diagnostic metadata. In particular, it has no
// field capable of carrying the resolved credential value.
type TokenInspection struct {
Source SourceInspection
Status TokenInspectionStatus
Present bool
ScopeState ScopeState
Scopes string
ExpiresAtMillis int64
RefreshExpiresAtMillis int64
GrantedAtMillis int64
}
// InspectSource returns a sanitized view of the selected credential source.
// Extension detection remains encapsulated here so commands do not need to
// know whether credentials came from env, a helper, or the built-in keychain.
func (p *CredentialProvider) InspectSource(ctx context.Context) (*SourceInspection, error) {
if p == nil {
return &SourceInspection{Name: "default"}, nil
}
acct, err := p.ResolveAccount(ctx)
info := &SourceInspection{Name: "default"}
if p.selectedProvider != nil {
info.Managed = true
info.Name = p.selectedProvider.Name()
if info.Name == "" {
info.Name = "external"
}
}
if err != nil {
// Source inspection must not replace the established error path for the
// built-in config/keychain source. Commands still load their normal
// config after this call and report the same command-specific error as
// before the inspection boundary existed. A selected managed provider,
// however, owns credential resolution, so its failure remains fail
// closed and must be surfaced here.
if info.Managed {
return info, err
}
return info, nil
}
if acct == nil {
return info, nil
}
fillSourceInspection(info, acct, p.selectedCaps)
return info, nil
}
// InspectToken reports token availability and metadata without returning the
// credential value. Scope resolution is opt-in because managed sources may
// need to perform work to obtain authoritative scope metadata.
func (p *CredentialProvider) InspectToken(ctx context.Context, req TokenInspectionRequest) (*TokenInspection, error) {
acct, err := p.ResolveAccount(ctx)
if err != nil {
return nil, err
}
source, err := p.selectedCredentialSource(ctx)
if err != nil {
return nil, err
}
info := SourceInspection{Name: "default"}
if source != nil {
info.Name = source.Name()
}
if p.selectedProvider != nil {
info.Managed = true
if info.Name == "" {
info.Name = "external"
}
}
if acct != nil {
fillSourceInspection(&info, acct, p.selectedCaps)
}
result := &TokenInspection{
Source: info,
Status: TokenInspectionMissing,
ScopeState: ScopeUnknown,
}
if acct == nil || source == nil {
return result, nil
}
if info.Managed {
return inspectManagedToken(ctx, source, acct, req, result)
}
return inspectDefaultToken(acct, req.TokenSpec, result), nil
}
func fillSourceInspection(info *SourceInspection, acct *Account, capabilities ProviderCapabilities) {
info.AppID = acct.AppID
info.Brand = acct.Brand
info.DefaultAs = acct.DefaultAs
info.ProfileName = acct.ProfileName
info.UserOpenID = acct.UserOpenId
info.UserName = acct.UserName
info.SupportedIdentities = acct.SupportedIdentities
info.ProvidesOnDemandAuth = capabilities.ProvidesOnDemandAuth
info.CanInspectScopes = capabilities.CanInspectScopes
}
func inspectDefaultToken(acct *Account, spec TokenSpec, result *TokenInspection) *TokenInspection {
switch spec.Type {
case TokenTypeTAT:
ids := extcred.IdentitySupport(acct.SupportedIdentities)
if ids.UserOnly() {
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result
}
if acct.SupportedIdentities == 0 && !HasRealAppSecret(acct.AppSecret) {
result.Status = TokenInspectionMissing
result.ScopeState = ScopeUnsupported
return result
}
result.Status = TokenInspectionReady
result.Present = true
result.ScopeState = ScopeUnsupported
return result
case TokenTypeUAT:
if acct.UserOpenId == "" {
result.Status = TokenInspectionNotLoggedIn
return result
}
stored := getStoredToken(acct.AppID, acct.UserOpenId)
if stored == nil {
result.Status = TokenInspectionMissing
return result
}
result.Present = true
result.ScopeState = ScopeKnown
result.Scopes = stored.Scope
result.ExpiresAtMillis = stored.ExpiresAt
result.RefreshExpiresAtMillis = stored.RefreshExpiresAt
result.GrantedAtMillis = stored.GrantedAt
switch getStoredTokenStatus(stored) {
case "valid":
result.Status = TokenInspectionReady
case "needs_refresh":
result.Status = TokenInspectionNeedsRefresh
default:
result.Status = TokenInspectionExpired
}
return result
default:
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result
}
}
func inspectManagedToken(
ctx context.Context,
source credentialSource,
acct *Account,
req TokenInspectionRequest,
result *TokenInspection,
) (*TokenInspection, error) {
if req.Type != TokenTypeUAT && req.Type != TokenTypeTAT {
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result, nil
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
if (req.Type == TokenTypeUAT && ids.BotOnly()) || (req.Type == TokenTypeTAT && ids.UserOnly()) {
result.Status = TokenInspectionNotSupported
result.ScopeState = ScopeUnsupported
return result, nil
}
if result.Source.ProvidesOnDemandAuth {
result.Status = TokenInspectionAvailableLive
result.Present = true
}
if req.Type == TokenTypeTAT && !result.Source.ProvidesOnDemandAuth {
result.Status = TokenInspectionReady
result.Present = true
result.ScopeState = ScopeUnsupported
}
if req.Type == TokenTypeUAT && !result.Source.ProvidesOnDemandAuth && acct.UserOpenId != "" {
result.Status = TokenInspectionReady
result.Present = true
}
if !req.IncludeScopes {
return result, nil
}
if !result.Source.CanInspectScopes {
result.ScopeState = ScopeUnsupported
return result, nil
}
token, found, err := source.TryResolveToken(ctx, req.TokenSpec)
if err != nil {
return nil, err
}
if !found {
result.Status = TokenInspectionMissing
result.Present = false
return result, nil
}
result.Status = TokenInspectionReady
result.Present = true
if strings.TrimSpace(token.Scopes) == "" {
result.ScopeState = ScopeUnknown
return result, nil
}
result.ScopeState = ScopeKnown
result.Scopes = token.Scopes
return result, nil
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"fmt"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/vfs"
)
type inspectionExtProvider struct {
capabilities ProviderCapabilities
token *extcred.Token
accountErr error
accountCalls int
tokenCalls int
}
func (p *inspectionExtProvider) Name() string { return "inspection-test" }
func (p *inspectionExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
p.accountCalls++
if p.accountErr != nil {
return nil, p.accountErr
}
return &extcred.Account{
AppID: "cli_test",
Brand: extcred.BrandFeishu,
SupportedIdentities: extcred.SupportsAll,
}, nil
}
func (p *inspectionExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
p.tokenCalls++
return p.token, nil
}
func (p *inspectionExtProvider) CredentialCapabilities() ProviderCapabilities {
return p.capabilities
}
func TestInspectSourceUsesSingleCachedProviderSelection(t *testing.T) {
ext := &inspectionExtProvider{}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
first, err := provider.InspectSource(context.Background())
if err != nil {
t.Fatalf("first InspectSource() error = %v", err)
}
second, err := provider.InspectSource(context.Background())
if err != nil {
t.Fatalf("second InspectSource() error = %v", err)
}
if ext.accountCalls != 1 {
t.Fatalf("ResolveAccount() calls = %d, want one immutable selection", ext.accountCalls)
}
if !first.Managed || first.Name != "inspection-test" || first.AppID != "cli_test" {
t.Fatalf("first inspection = %#v", first)
}
if *first != *second {
t.Fatalf("source inspection changed: first=%#v second=%#v", first, second)
}
}
func TestInspectSourcePreservesDefaultCommandErrorPath(t *testing.T) {
resolveErr := errors.New("default account is not configured")
provider := NewCredentialProvider(nil, &mockDefaultAcct{err: resolveErr}, nil, nil)
got, err := provider.InspectSource(context.Background())
if err != nil {
t.Fatalf("InspectSource() error = %v, want default resolution deferred to the command", err)
}
if got == nil || got.Managed || got.Name != "default" {
t.Fatalf("InspectSource() = %#v, want unmanaged default source", got)
}
}
func TestInspectSourceManagedFailureRemainsFailClosed(t *testing.T) {
resolveErr := errors.New("managed provider unavailable")
ext := &inspectionExtProvider{accountErr: resolveErr}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
got, err := provider.InspectSource(context.Background())
if !errors.Is(err, resolveErr) {
t.Fatalf("InspectSource() error = %v, want %v", err, resolveErr)
}
if got == nil || !got.Managed || got.Name != "inspection-test" {
t.Fatalf("InspectSource() = %#v, want selected managed source", got)
}
}
func TestInspectToken_DefaultPreservesStoredScopeMetadataWithoutCredentialValue(t *testing.T) {
originalGet := getStoredToken
originalStatus := getStoredTokenStatus
t.Cleanup(func() {
getStoredToken = originalGet
getStoredTokenStatus = originalStatus
})
getStoredToken = func(appID, openID string) *auth.StoredUAToken {
return &auth.StoredUAToken{
AppId: appID,
UserOpenId: openID,
AccessToken: "must-not-leak",
RefreshToken: "must-not-leak-refresh",
Scope: "im:message docx:document",
ExpiresAt: 11,
RefreshExpiresAt: 22,
GrantedAt: 33,
}
}
getStoredTokenStatus = func(*auth.StoredUAToken) string { return "valid" }
provider := NewCredentialProvider(nil, &mockDefaultAcct{account: &Account{
AppID: "cli_test", UserOpenId: "ou_test",
}}, &mockDefaultToken{}, nil)
got, err := provider.InspectToken(context.Background(), TokenInspectionRequest{
TokenSpec: TokenSpec{Type: TokenTypeUAT, AppID: "cli_test"},
})
if err != nil {
t.Fatalf("InspectToken() error = %v", err)
}
if !got.Present || got.Status != TokenInspectionReady || got.ScopeState != ScopeKnown {
t.Fatalf("inspection = %#v", got)
}
if got.Scopes != "im:message docx:document" || got.ExpiresAtMillis != 11 ||
got.RefreshExpiresAtMillis != 22 || got.GrantedAtMillis != 33 {
t.Fatalf("metadata = %#v", got)
}
if rendered := fmt.Sprintf("%+v", got); strings.Contains(rendered, "must-not-leak") {
t.Fatalf("inspection leaked credential value: %s", rendered)
}
}
func TestTokenInspectionHasNoCredentialValueField(t *testing.T) {
typ := reflect.TypeOf(TokenInspection{})
for i := 0; i < typ.NumField(); i++ {
name := strings.ToLower(typ.Field(i).Name)
for _, forbidden := range []string{"token", "secret", "credential", "value"} {
if strings.Contains(name, forbidden) {
t.Fatalf("TokenInspection field %q could carry a credential value", typ.Field(i).Name)
}
}
}
}
func TestInspectToken_UninspectableManagedScopesDoNotResolveToken(t *testing.T) {
ext := &inspectionExtProvider{
capabilities: ProviderCapabilities{ProvidesOnDemandAuth: true, CanInspectScopes: false},
token: &extcred.Token{Value: "opaque-placeholder", Scopes: "must:not:be:used"},
}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
got, err := provider.InspectToken(context.Background(), TokenInspectionRequest{
TokenSpec: TokenSpec{Type: TokenTypeUAT, AppID: "cli_test"},
IncludeScopes: true,
})
if err != nil {
t.Fatalf("InspectToken() error = %v", err)
}
if got.ScopeState != ScopeUnsupported || !got.Present || got.Status != TokenInspectionAvailableLive {
t.Fatalf("inspection = %#v", got)
}
if ext.tokenCalls != 0 {
t.Fatalf("ResolveToken() calls = %d, want 0", ext.tokenCalls)
}
}
func TestInspectToken_InspectableManagedSourceDistinguishesScopeState(t *testing.T) {
for _, test := range []struct {
name string
scopes string
wantState ScopeState
}{
{name: "known", scopes: "im:message", wantState: ScopeKnown},
{name: "unknown", scopes: "", wantState: ScopeUnknown},
{name: "blank is unknown", scopes: " ", wantState: ScopeUnknown},
} {
t.Run(test.name, func(t *testing.T) {
ext := &inspectionExtProvider{
capabilities: ProviderCapabilities{ProvidesOnDemandAuth: true, CanInspectScopes: true},
token: &extcred.Token{Value: "external-secret-token", Scopes: test.scopes},
}
provider := NewCredentialProvider([]extcred.Provider{ext}, nil, nil, nil)
got, err := provider.InspectToken(context.Background(), TokenInspectionRequest{
TokenSpec: TokenSpec{Type: TokenTypeUAT, AppID: "cli_test"},
IncludeScopes: true,
})
if err != nil {
t.Fatalf("InspectToken() error = %v", err)
}
if got.ScopeState != test.wantState || !got.Present {
t.Fatalf("inspection = %#v, want scope state %q", got, test.wantState)
}
if rendered := fmt.Sprintf("%+v", got); strings.Contains(rendered, "external-secret-token") {
t.Fatalf("inspection leaked credential value: %s", rendered)
}
})
}
}
func TestDiagnosticCommandsUseCredentialInspectionBoundary(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller() could not locate test source")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
files := []string{
"cmd/auth/check.go",
"cmd/auth/status.go",
"cmd/config/show.go",
"cmd/doctor/doctor.go",
"internal/identitydiag/diagnostics.go",
}
forbidden := []string{
"ActiveExtensionProviderName(",
"GetStoredToken(",
"TokenStatus(",
"internal/keychain",
"internal/externalcredential",
"os.Getenv(",
".ExternalCredential.Mode",
"ExternalCredential != nil",
"ExternalCredential == nil",
}
for _, relative := range files {
data, err := vfs.ReadFile(filepath.Join(repoRoot, relative))
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", relative, err)
}
for _, token := range forbidden {
if strings.Contains(string(data), token) {
t.Errorf("%s bypasses credential inspection with %q", relative, token)
}
}
}
}

View File

@@ -12,6 +12,10 @@ const (
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Developer-only path override used by both editions when detecting the
// system-managed external credential configuration.
CliExternalCredentialConfig = "LARKSUITE_CLI_EXTERNAL_CREDENTIAL_CONFIG"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar

View File

@@ -107,21 +107,22 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Hint = detailHint
}
var classified error
switch meta.Category {
case errs.CategoryAuthorization:
return buildPermissionError(base, resp, cc)
classified = buildPermissionError(base, resp, cc)
case errs.CategoryAuthentication:
return &errs.AuthenticationError{Problem: base}
classified = &errs.AuthenticationError{Problem: base}
case errs.CategoryConfig:
return buildConfigError(base)
classified = buildConfigError(base)
case errs.CategoryPolicy:
return buildSecurityPolicyError(base, resp)
classified = buildSecurityPolicyError(base, resp)
case errs.CategoryValidation:
return &errs.ValidationError{Problem: base}
classified = &errs.ValidationError{Problem: base}
case errs.CategoryNetwork:
return &errs.NetworkError{Problem: base}
classified = &errs.NetworkError{Problem: base}
case errs.CategoryInternal:
return &errs.InternalError{Problem: base}
classified = &errs.InternalError{Problem: base}
case errs.CategoryConfirmation:
// Risk + Action are non-omitempty wire fields. Derive from
// CodeMeta when available; otherwise emit RiskUnknown +
@@ -137,7 +138,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
if action == "" {
action = "unknown"
}
return &errs.ConfirmationRequiredError{
classified = &errs.ConfirmationRequiredError{
Problem: base,
Risk: risk,
Action: action,
@@ -148,20 +149,24 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base}
classified = &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
// instead of emitting an empty Problem on the wire.
return &errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeSDKError,
Code: base.Code,
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
LogID: base.LogID,
return errs.WithDiagnosticMetadata(
&errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeSDKError,
Code: base.Code,
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
LogID: base.LogID,
},
},
}
errs.DiagnosticMetadata{Origin: larkErrorOrigin()},
)
}
return errs.WithDiagnosticMetadata(classified, errs.DiagnosticMetadata{Origin: larkErrorOrigin()})
}
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API

View File

@@ -108,6 +108,10 @@ func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) {
if ie.Code != stubCode {
t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode)
}
metadata, _ := errs.DiagnosticMetadataOf(err)
if metadata.Origin != larkErrorOrigin() {
t.Errorf("Origin = %q, want %q", metadata.Origin, larkErrorOrigin())
}
}
// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a

View File

@@ -11,6 +11,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
@@ -59,6 +60,22 @@ func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
}
}
func TestBuildAPIErrorMarksLarkOrigin(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": 99991663,
"msg": "token invalid",
}, errclass.ClassifyContext{})
problem, ok := errs.ProblemOf(err)
wantOrigin := ""
if build.Edition == "extended" {
wantOrigin = "lark"
}
metadata, _ := errs.DiagnosticMetadataOf(err)
if !ok || metadata.Origin != wantOrigin {
t.Fatalf("problem = %#v, metadata = %#v, ok = %v, want origin %q", problem, metadata, ok, wantOrigin)
}
}
// matchesTypedError reports whether err is the typed-error variant identified by
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
@@ -90,14 +107,30 @@ func matchesTypedError(err error, wantTyped string) bool {
var x *errs.SecurityPolicyError
return errors.As(err, &x)
case "APIError":
// APIError is the default fallback; use a direct type assertion to avoid
// matching against typed subclasses that also satisfy IsAPI.
_, ok := err.(*errs.APIError)
return ok
var x *errs.APIError
return errors.As(err, &x)
}
return false
}
func requirePermissionError(t *testing.T, err error) *errs.PermissionError {
t.Helper()
var permission *errs.PermissionError
if !errors.As(err, &permission) {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
return permission
}
func requireSecurityPolicyError(t *testing.T, err error) *errs.SecurityPolicyError {
t.Helper()
var policy *errs.SecurityPolicyError
if !errors.As(err, &policy) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T", err)
}
return policy
}
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
cases := []struct {
name string
@@ -419,10 +452,7 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) {
func TestConsoleURL_FeishuBrand(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
pe := requirePermissionError(t, err)
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
}
@@ -431,10 +461,7 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
func TestConsoleURL_LarkBrand(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
pe := requirePermissionError(t, err)
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
}
@@ -443,7 +470,7 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
func TestConsoleURL_EmptyAppID(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
pe := err.(*errs.PermissionError)
pe := requirePermissionError(t, err)
if pe.ConsoleURL != "" {
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
}
@@ -459,13 +486,13 @@ func TestConsoleURL_EmptyAppID(t *testing.T) {
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
bot := requirePermissionError(t, errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc))
if bot.ConsoleURL == "" {
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
}
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
user := requirePermissionError(t, errclass.BuildAPIError(missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}))
if user.ConsoleURL != "" {
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL)
}
@@ -538,7 +565,7 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
func TestPermissionError_DefaultIdentity(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
pe := err.(*errs.PermissionError)
pe := requirePermissionError(t, err)
if pe.Identity != "user" {
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
}
@@ -550,7 +577,7 @@ func TestPermissionError_NoViolations(t *testing.T) {
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
resp := map[string]any{"code": 99991672, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe := err.(*errs.PermissionError)
pe := requirePermissionError(t, err)
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
@@ -573,7 +600,7 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := err.(*errs.PermissionError)
pe := requirePermissionError(t, err)
if got, want := len(pe.MissingScopes), 2; got != want {
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
}
@@ -608,9 +635,7 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
resp := missingScopeResp(missing[0])
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
}
requirePermissionError(t, dispatcherErr)
// Path B: direct construction — exercises the same helpers that
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
@@ -632,7 +657,9 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
t.Fatal("direct path failed to emit typed envelope")
}
// Strip `code` from both envelopes — see test doc above.
// Strip fields that only exist when the error came from an upstream Lark
// response. The remaining fields must converge with the local preflight
// error.
stripA := stripUpstreamFields(t, bufA.Bytes())
stripB := stripUpstreamFields(t, bufB.Bytes())
if stripA != stripB {
@@ -640,9 +667,9 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
}
}
// stripUpstreamFields parses an envelope JSON and re-marshals it with the
// upstream-derived "code" key removed from the inner "error" block. Used by
// the convergence test to isolate contract fields shared between the
// stripUpstreamFields parses an envelope JSON and re-marshals it with fields
// that identify an upstream Lark response removed from the inner "error"
// block. Used by the convergence test to isolate fields shared between the
// dispatcher and pre-flight paths.
func stripUpstreamFields(t *testing.T, raw []byte) string {
t.Helper()
@@ -652,6 +679,7 @@ func stripUpstreamFields(t *testing.T, raw []byte) string {
}
if errBlock, ok := obj["error"].(map[string]any); ok {
delete(errBlock, "code")
delete(errBlock, "origin")
}
out, err := json.Marshal(obj)
if err != nil {
@@ -838,10 +866,7 @@ func TestBuildPermissionError_CanonicalMessage(t *testing.T) {
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *PermissionError, got %T", err)
}
pe := requirePermissionError(t, err)
if pe.Subtype != tc.wantSubtype {
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
}
@@ -938,9 +963,7 @@ func TestBuildAPIError_JSONNumberCode(t *testing.T) {
if err == nil {
t.Fatal("expected error for json.Number-encoded code")
}
if _, ok := err.(*errs.PermissionError); !ok {
t.Errorf("expected *errs.PermissionError, got %T", err)
}
requirePermissionError(t, err)
}
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
@@ -958,10 +981,7 @@ func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
spe := requireSecurityPolicyError(t, err)
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
}
@@ -981,10 +1001,7 @@ func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
spe := requireSecurityPolicyError(t, err)
if spe.Hint != "ask your admin for elevated approval" {
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
}
@@ -1008,10 +1025,7 @@ func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
"data": map[string]any{"challenge_url": bad, "hint": "h"},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
spe := requireSecurityPolicyError(t, err)
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
}
@@ -1025,10 +1039,7 @@ func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
resp := map[string]any{"code": 21000, "msg": "challenge required"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
spe := requireSecurityPolicyError(t, err)
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
}
@@ -1062,10 +1073,7 @@ func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
}
}()
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
}
spe := requireSecurityPolicyError(t, err)
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
}
@@ -1088,10 +1096,7 @@ func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
spe := requireSecurityPolicyError(t, err)
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
}

View File

@@ -38,13 +38,11 @@ var codeMeta = map[int]CodeMeta{
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
20024: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // authorization code or refresh_token does not match client_id
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token is invalid or v1 legacy format
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
20072: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError}, // refresh endpoint temporarily unavailable
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
// CategoryAuthorization
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
@@ -53,13 +51,6 @@ var codeMeta = map[int]CodeMeta{
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant
20008: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user does not exist
20009: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified is not installed
20010: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user does not have permission to use this app
20048: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified is not exist
20066: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user staus is not normal
20069: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app specified is disabled
20074: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified not allows for refresh token
// CategoryAPI
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
@@ -71,17 +62,10 @@ var codeMeta = map[int]CodeMeta{
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
20001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request missing required parameter
20036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // grant_type not supported
20063: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request format error
20067: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // scope list contains duplicated items
20068: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // scope list contains forbidden permissions
20070: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request provide multiple authorization methods
// CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
20002: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // client secret invalid
// CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -23,27 +23,11 @@ func TestLookupCodeMeta_CredentialCodes(t *testing.T) {
{99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false},
{20024, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false},
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
{20072, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, false},
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
{20008, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20009, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20010, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20048, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20066, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20069, errs.CategoryAuthorization, errs.SubtypeAppDisabled, false},
{20074, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20036, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20063, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20067, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20068, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20070, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20002, errs.CategoryConfig, errs.SubtypeInvalidClient, false},
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package errclass
func larkErrorOrigin() string { return "lark" }

View File

@@ -0,0 +1,9 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package errclass
// Standard keeps the pre-Extended API error envelope unchanged.
func larkErrorOrigin() string { return "" }

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package extendedupdate
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
extendedStateFile = "update-state-extended.json"
extendedCacheTTL = 24 * time.Hour
)
type cachedRelease struct {
LatestVersion string `json:"latest_version"`
CheckedAt int64 `json:"checked_at"`
}
// CheckCached reads only the Extended release cache. Standard and Extended
// deliberately use different files so one edition can never advertise the
// other edition's release channel.
func CheckCached(currentVersion string) *update.UpdateInfo {
if skipUpdateNotice(currentVersion) {
return nil
}
state, err := loadCachedRelease()
if err != nil || state.LatestVersion == "" ||
!update.IsNewer(state.LatestVersion, currentVersion) {
return nil
}
return &update.UpdateInfo{Current: currentVersion, Latest: state.LatestVersion}
}
// RefreshCache refreshes the Extended GitHub-release cache when stale. It is
// intentionally best-effort because callers run it from the notice goroutine.
func RefreshCache(currentVersion string) {
if skipUpdateNotice(currentVersion) {
return
}
state, _ := loadCachedRelease()
if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < extendedCacheTTL {
return
}
latest, err := FetchLatest()
if err != nil {
return
}
_ = saveCachedRelease(&cachedRelease{
LatestVersion: latest,
CheckedAt: time.Now().Unix(),
})
}
func skipUpdateNotice(version string) bool {
if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" || update.IsCIEnv() {
return true
}
return !update.IsRelease(version)
}
func extendedStatePath() string {
return filepath.Join(core.GetConfigDir(), extendedStateFile)
}
func loadCachedRelease() (*cachedRelease, error) {
data, err := vfs.ReadFile(extendedStatePath())
if err != nil {
return nil, err
}
var state cachedRelease
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
func saveCachedRelease(state *cachedRelease) error {
dir := core.GetConfigDir()
if err := vfs.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return validate.AtomicWrite(extendedStatePath(), data, 0o600)
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package extendedupdate
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs"
)
func TestCheckCachedUsesExtendedEditionState(t *testing.T) {
for _, key := range []string{
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER",
"CI",
"BUILD_NUMBER",
"RUN_ID",
} {
t.Setenv(key, "")
}
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
standardState, err := json.Marshal(cachedRelease{
LatestVersion: "9.0.0",
CheckedAt: time.Now().Unix(),
})
if err != nil {
t.Fatal(err)
}
if err := vfs.WriteFile(filepath.Join(dir, "update-state.json"), standardState, 0o600); err != nil {
t.Fatal(err)
}
if got := CheckCached("1.0.0"); got != nil {
t.Fatalf("Standard cache leaked into Extended notice: %+v", got)
}
extendedState, err := json.Marshal(cachedRelease{
LatestVersion: "2.0.0",
CheckedAt: time.Now().Unix(),
})
if err != nil {
t.Fatal(err)
}
if err := vfs.WriteFile(filepath.Join(dir, extendedStateFile), extendedState, 0o600); err != nil {
t.Fatal(err)
}
got := CheckCached("1.0.0")
if got == nil || got.Current != "1.0.0" || got.Latest != "2.0.0" {
t.Fatalf("CheckCached() = %+v, want Extended 1.0.0 -> 2.0.0", got)
}
}

View File

@@ -0,0 +1,323 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
// Package extendedupdate updates an Extended binary from the matching
// lark-cli-extended asset in GitHub Releases.
package extendedupdate
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
const (
latestReleaseURL = "https://api.github.com/repos/larksuite/cli/releases/latest"
releaseBaseURL = "https://github.com/larksuite/cli/releases/download"
maxChecksumBytes = 1 << 20
maxArchiveBytes = 256 << 20
requestTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
)
var httpClient = newHTTPClient()
type release struct {
TagName string `json:"tag_name"`
}
type versionInfo struct {
Version string `json:"version"`
Edition string `json:"edition"`
}
func newHTTPClient() *http.Client {
client := transport.NewHTTPClient(requestTimeout)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" || !trustedDownloadHost(req.URL.Hostname()) {
return fmt.Errorf("release download redirected to an untrusted URL: %s", req.URL.Redacted())
}
if len(via) >= 5 {
return fmt.Errorf("release download exceeded redirect limit")
}
return nil
}
return client
}
func trustedDownloadHost(host string) bool {
host = strings.ToLower(host)
return host == "github.com" ||
host == "api.github.com" ||
strings.HasSuffix(host, ".githubusercontent.com")
}
// FetchLatest returns the latest release version. Install verifies that the
// matching Extended asset and checksum entry exist before replacing anything.
func FetchLatest() (string, error) {
body, err := download(latestReleaseURL, maxChecksumBytes)
if err != nil {
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to query the latest Extended release: %v", err).WithCause(err)
}
var latest release
if err := json.Unmarshal(body, &latest); err != nil {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"GitHub returned an invalid latest release response").WithCause(err)
}
version := strings.TrimPrefix(strings.TrimSpace(latest.TagName), "v")
if version == "" || strings.ContainsAny(version, `/\`) {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"GitHub returned an invalid latest release tag")
}
return version, nil
}
// Install downloads, verifies, and atomically replaces the running Extended
// binary with the requested Extended release asset.
func Install(version string) error {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
archiveName, err := assetName(version, runtime.GOOS, runtime.GOARCH)
if err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%v", err).WithCause(err)
}
base := releaseBaseURL + "/v" + version
checksums, err := download(base+"/checksums.txt", maxChecksumBytes)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to download Extended release checksums: %v", err).WithCause(err)
}
expected, err := checksumFor(checksums, archiveName)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Extended release checksum is invalid: %v", err).WithCause(err)
}
archive, err := download(base+"/"+archiveName, maxArchiveBytes)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to download Extended release asset: %v", err).WithCause(err)
}
actual := sha256.Sum256(archive)
if !bytes.Equal(actual[:], expected) {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Extended release checksum verification failed")
}
binary, err := extractBinary(archive, runtime.GOOS)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Extended release archive is invalid: %v", err).WithCause(err)
}
if err := replaceCurrent(binary, version); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to install lark-cli Extended: %v", err).
WithCause(err).
WithHint("ensure the current lark-cli installation directory is writable")
}
return nil
}
func download(rawURL string, limit int64) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "lark-cli-extended")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("response exceeds %d bytes", limit)
}
return body, nil
}
func assetName(version, goos, goarch string) (string, error) {
switch goos {
case "darwin", "linux", "windows":
default:
return "", fmt.Errorf("Extended update does not support %s", goos)
}
switch goarch {
case "amd64", "arm64", "riscv64":
default:
return "", fmt.Errorf("Extended update does not support %s/%s", goos, goarch)
}
ext := ".tar.gz"
if goos == "windows" {
ext = ".zip"
}
return fmt.Sprintf("lark-cli-extended-%s-%s-%s%s", version, goos, goarch, ext), nil
}
func checksumFor(data []byte, asset string) ([]byte, error) {
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != asset {
continue
}
sum, err := hex.DecodeString(fields[0])
if err != nil || len(sum) != sha256.Size {
return nil, fmt.Errorf("invalid SHA-256 for %s", asset)
}
return sum, nil
}
return nil, fmt.Errorf("checksums.txt does not contain %s", asset)
}
func extractBinary(archive []byte, goos string) ([]byte, error) {
name := "lark-cli"
if goos == "windows" {
name += ".exe"
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
return nil, err
}
for _, file := range reader.File {
if path.Clean(file.Name) != name || file.FileInfo().IsDir() {
continue
}
rc, err := file.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return readBinary(rc)
}
return nil, fmt.Errorf("%s is missing", name)
}
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if path.Clean(header.Name) == name && header.Typeflag == tar.TypeReg {
return readBinary(tr)
}
}
return nil, fmt.Errorf("%s is missing", name)
}
func readBinary(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxArchiveBytes+1))
if err != nil {
return nil, err
}
if len(data) == 0 || int64(len(data)) > maxArchiveBytes {
return nil, fmt.Errorf("binary has invalid size")
}
return data, nil
}
func replaceCurrent(binary []byte, version string) error {
exe, err := vfs.Executable()
if err != nil {
return err
}
exe, err = vfs.EvalSymlinks(exe)
if err != nil {
return err
}
dir := filepath.Dir(exe)
tmp, err := vfs.CreateTemp(dir, ".lark-cli-extended-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer vfs.Remove(tmpName)
if _, err := tmp.Write(binary); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o755); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := verifyBinary(tmpName, version); err != nil {
return err
}
backup := exe + ".old"
_ = vfs.Remove(backup)
if err := vfs.Rename(exe, backup); err != nil {
return err
}
restore := func() {
_ = vfs.Remove(exe)
_ = vfs.Rename(backup, exe)
}
if err := vfs.Rename(tmpName, exe); err != nil {
restore()
return err
}
if err := verifyBinary(exe, version); err != nil {
restore()
return err
}
_ = vfs.Remove(backup)
return nil
}
func verifyBinary(exe, version string) error {
ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, exe, "version", "--json").Output()
if err != nil {
return fmt.Errorf("candidate binary is not executable: %w", err)
}
var info versionInfo
if err := json.Unmarshal(out, &info); err != nil {
return fmt.Errorf("candidate returned invalid version metadata: %w", err)
}
if strings.TrimPrefix(info.Version, "v") != strings.TrimPrefix(version, "v") {
return fmt.Errorf("candidate version is %q, want %q", info.Version, version)
}
if info.Edition != "extended" {
return fmt.Errorf("candidate edition is %q, want extended", info.Edition)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More