mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
refactor/o
...
feat/exter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb68c317a |
126
.github/workflows/ci.yml
vendored
126
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
9
Makefile
9
Makefile
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
58
cmd/auth/status_edition_extended.go
Normal file
58
cmd/auth/status_edition_extended.go
Normal 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
|
||||
}
|
||||
54
cmd/auth/status_edition_extended_test.go
Normal file
54
cmd/auth/status_edition_extended_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
cmd/auth/status_edition_standard.go
Normal file
21
cmd/auth/status_edition_standard.go
Normal 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
|
||||
}
|
||||
49
cmd/auth/status_edition_standard_test.go
Normal file
49
cmd/auth/status_edition_standard_test.go
Normal 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"], ¬e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(note, "lark-cli auth login") {
|
||||
t.Fatalf("Standard note = %q, want established login guidance", note)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
43
cmd/build.go
43
cmd/build.go
@@ -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
176
cmd/build_workspace_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
73
cmd/config/show_edition_extended.go
Normal file
73
cmd/config/show_edition_extended.go
Normal 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)
|
||||
}
|
||||
93
cmd/config/show_edition_extended_test.go
Normal file
93
cmd/config/show_edition_extended_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
12
cmd/config/show_edition_standard.go
Normal file
12
cmd/config/show_edition_standard.go
Normal 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
|
||||
}
|
||||
54
cmd/config/show_edition_standard_test.go
Normal file
54
cmd/config/show_edition_standard_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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(), "")}
|
||||
}
|
||||
|
||||
94
cmd/doctor/doctor_edition_extended.go
Normal file
94
cmd/doctor/doctor_edition_extended.go
Normal 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")
|
||||
}
|
||||
75
cmd/doctor/doctor_edition_extended_test.go
Normal file
75
cmd/doctor/doctor_edition_extended_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
24
cmd/doctor/doctor_edition_standard.go
Normal file
24
cmd/doctor/doctor_edition_standard.go
Normal 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)
|
||||
}
|
||||
52
cmd/doctor/doctor_edition_standard_test.go
Normal file
52
cmd/doctor/doctor_edition_standard_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
12
cmd/doctor/update_extended.go
Normal file
12
cmd/doctor/update_extended.go
Normal 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()
|
||||
}
|
||||
12
cmd/doctor/update_standard.go
Normal file
12
cmd/doctor/update_standard.go
Normal 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()
|
||||
}
|
||||
74
cmd/doctor_startup_standard_test.go
Normal file
74
cmd/doctor_startup_standard_test.go
Normal 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())
|
||||
}
|
||||
17
cmd/edition_commands_extended.go
Normal file
17
cmd/edition_commands_extended.go
Normal 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))
|
||||
}
|
||||
28
cmd/edition_commands_extended_test.go
Normal file
28
cmd/edition_commands_extended_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
20
cmd/edition_commands_standard.go
Normal file
20
cmd/edition_commands_standard.go
Normal 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))
|
||||
}
|
||||
28
cmd/edition_commands_standard_test.go
Normal file
28
cmd/edition_commands_standard_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
146
cmd/event/external_credential_guard_test.go
Normal file
146
cmd/event/external_credential_guard_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
222
cmd/profile/runtime_capabilities_test.go
Normal file
222
cmd/profile/runtime_capabilities_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
16
cmd/root.go
16
cmd/root.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
100
cmd/update/edition_extended.go
Normal file
100
cmd/update/edition_extended.go
Normal 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
|
||||
}
|
||||
80
cmd/update/edition_extended_test.go
Normal file
80
cmd/update/edition_extended_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
cmd/update/edition_standard.go
Normal file
20
cmd/update/edition_standard.go
Normal 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.`
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !extended
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
|
||||
19
cmd/update_notice_extended.go
Normal file
19
cmd/update_notice_extended.go
Normal 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)
|
||||
}
|
||||
16
cmd/update_notice_standard.go
Normal file
16
cmd/update_notice_standard.go
Normal 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
56
cmd/version/version.go
Normal 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
|
||||
}
|
||||
67
cmd/version/version_test.go
Normal file
67
cmd/version/version_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
8
cmd/version/visibility_extended.go
Normal file
8
cmd/version/visibility_extended.go
Normal 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 }
|
||||
10
cmd/version/visibility_standard.go
Normal file
10
cmd/version/visibility_standard.go
Normal 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 }
|
||||
@@ -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
134
errs/diagnostic_metadata.go
Normal 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
|
||||
}
|
||||
118
errs/diagnostic_metadata_test.go
Normal file
118
errs/diagnostic_metadata_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
12
internal/build/edition_extended.go
Normal file
12
internal/build/edition_extended.go
Normal 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"}
|
||||
}
|
||||
10
internal/build/edition_standard.go
Normal file
10
internal/build/edition_standard.go
Normal 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{} }
|
||||
@@ -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}
|
||||
|
||||
169
internal/client/remote_files.go
Normal file
169
internal/client/remote_files.go
Normal 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()
|
||||
}
|
||||
317
internal/client/remote_files_architecture_test.go
Normal file
317
internal/client/remote_files_architecture_test.go
Normal 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
|
||||
}
|
||||
177
internal/client/remote_files_test.go
Normal file
177
internal/client/remote_files_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
261
internal/cmdutil/factory_external_credential_test.go
Normal file
261
internal/cmdutil/factory_external_credential_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
112
internal/cmdutil/runtime_capabilities.go
Normal file
112
internal/cmdutil/runtime_capabilities.go
Normal 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)...)
|
||||
}
|
||||
96
internal/cmdutil/runtime_capabilities_test.go
Normal file
96
internal/cmdutil/runtime_capabilities_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
271
internal/credential/inspection.go
Normal file
271
internal/credential/inspection.go
Normal 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
|
||||
}
|
||||
243
internal/credential/inspection_test.go
Normal file
243
internal/credential/inspection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
8
internal/errclass/origin_extended.go
Normal file
8
internal/errclass/origin_extended.go
Normal 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" }
|
||||
9
internal/errclass/origin_standard.go
Normal file
9
internal/errclass/origin_standard.go
Normal 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 "" }
|
||||
98
internal/extendedupdate/cache.go
Normal file
98
internal/extendedupdate/cache.go
Normal 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)
|
||||
}
|
||||
57
internal/extendedupdate/cache_test.go
Normal file
57
internal/extendedupdate/cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
323
internal/extendedupdate/update.go
Normal file
323
internal/extendedupdate/update.go
Normal 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
Reference in New Issue
Block a user