Compare commits

...

84 Commits

Author SHA1 Message Date
luozhixiong
4769b5c3e8 docs(profile): clarify identity diagnostics heading 2026-07-27 11:10:16 +08:00
luozhixiong
c86c348fa9 fix(auth): reserve incomplete credential arbitration for env 2026-07-27 11:10:16 +08:00
luozhixiong
8587203afb test(sidecar): align fake provider with app binding 2026-07-27 10:56:13 +08:00
luozhixiong
48b0ade294 fix(profile): honor an explicit empty profile flag 2026-07-27 10:56:13 +08:00
luozhixiong
0fef0667fd fix(auth): classify invalid sidecar policy 2026-07-27 10:56:13 +08:00
luozhixiong
754f471f75 test(auth): align token fixtures with account selection 2026-07-27 10:56:13 +08:00
luozhixiong
8bcb04c830 test(auth): assert token dispatch call counts 2026-07-27 10:56:12 +08:00
luozhixiong
54428113f5 fix(auth): enforce app binding before token resolution 2026-07-27 10:56:12 +08:00
luozhixiong
01fdf2f4d6 docs(lark-shared): format profile selection guidance 2026-07-27 10:56:12 +08:00
luozhixiong
3a9cfdda93 chore: use a recognized token placeholder and trim skill routing prose
- The canned TAT test token tripped the deterministic gate's
  public-content rule; switch to the recognized your-access-token
  placeholder (no assertion semantics change).
- Drop the implementation mechanisms from the lark-shared frontmatter
  description (WHAT only; the body owns HOW) and tighten the
  clear-session-identity routing entry.
2026-07-27 10:56:12 +08:00
luozhixiong
51f3f07f44 docs(lark-shared): route pin/clear-session-identity intents from eval evidence
Two skill-eval failures on the final acceptance run, both fixed at the
smallest possible surface:

- A pure standing instruction ("run everything as tenant A from now
  on") triggers no tool call, so only the frontmatter description is
  visible — and the main description has no profile/tenant trigger
  words, leaving the body's routing table unloaded (eval case 2, agent
  answered with an unactionable promise). Add one 14-word clause:
  pinning/clearing a profile/tenant identity for a task or session.
  This partially revisits the earlier description revert, now backed by
  a concrete discovery failure rather than speculation.
- The body routing table only routed the SET direction; add the reverse
  entry: clearing the session identity -> unset LARKSUITE_CLI_PROFILE
  (session-scoped, idempotent; never profile use / profile remove)
  (eval case 4).

Skill eval rerun: 7/7 PASS.
2026-07-27 10:56:12 +08:00
luozhixiong
16ed51252e test(credential): pin the TAT mint-then-cache happy path
First resolve mints over HTTP, second is served from the sync.Once
cache with exactly one HTTP call total.
2026-07-27 10:56:12 +08:00
luozhixiong
6b80706300 fix(credential): unforgeable AccountDirect reservation, gate aligned with arbitration
- The AccountDirect reservation is enforced by concrete type
  (*envprovider.Provider), not Name() == "env" (review F2): the
  registry reserves neither names nor uniqueness, so any provider could
  previously impersonate the builtin env provider with a name string. A
  regression test proves a name-forging AccountDirect provider is
  rejected.
- The engagement probe now translates an invalid_policy block into the
  same typed validation error as formal arbitration (review F4),
  instead of skipping it and letting a later provider be reported as an
  external takeover. A multi-provider test pins probe/arbitration
  producing identical errors.
2026-07-27 10:56:12 +08:00
luozhixiong
76bd47a1c8 fix(credential): refuse mismatched tokens before any token work
The TAT path validated TokenSpec.AppID only AFTER doResolveTAT had
re-read the config, minted a token with the (possibly different) app's
secret, and cached it — so an A→B config race still produced a network
mint, quota use, and audit trail for the wrong app before the result
was refused (review F1). The account is now resolved and checked
against the request before the sync.Once mint; doResolveTAT receives
the already-checked account instead of re-reading; and the cached
result stays re-checked on every hit. The regression test asserts the
HTTP client is never even constructed for a mismatched request.

TokenSpec.AppID is now REQUIRED on the default token paths (review F3):
an empty value silently disabled the consistency guarantee. Every
production caller already passes it via NewTokenSpec.
2026-07-27 10:56:12 +08:00
luozhixiong
f6732c9afa fix(profile): keep only the renamed default field in profile list
Drop the dual-published deprecated `active` alias after review
discussion: a compat field with no removal owner becomes permanent, it
always mirrors `default` (inviting readers to hunt for a nonexistent
difference), and its historic name keeps misleading agents into reading
it as the currently effective identity — which is whoami's job. The
rename is declared as a breaking change in the PR body; no in-repo
consumer exists and no external consumer was identified.
2026-07-27 10:56:12 +08:00
luozhixiong
e28f91794a fix(credential): keep policy mistakes and saved-config inspection off the external gate
- The engagement probe no longer counts an invalid_policy block as an
  external credential takeover (review F4): auth/config commands were
  answering "credentials are provided externally" for a mistyped
  LARKSUITE_CLI_DEFAULT_AS/STRICT_MODE, while whoami correctly said
  invalid_argument. The probe skips such blocks; the command's own
  resolution then surfaces the precise typed error.
- config show overrides the parent gate entirely: it inspects the SAVED
  config only (its help promises "saved config, not current usage"),
  so the currently effective credential source must not decide whether
  it runs (review delivery finding). The blocked-commands pin drops
  show and a command-level test locks the bypass.
- AccountDirect is now an enforced contract, not a comment (review F5):
  only the builtin env provider may declare it, and gather fails fast
  otherwise instead of producing self-contradictory diagnostics. The
  SPI documents the reservation.
2026-07-27 10:56:12 +08:00
luozhixiong
dd42477a82 fix(credential): refuse cross-app tokens after a config edit
The account-level consistency check closed only half of the read-twice
race (review F2): DefaultTokenProvider re-reads the config for both UAT
and TAT, so a profile edit between account resolution and token
resolution could still hand back a token minted for a different app,
and TokenSpec.AppID was never consulted.

Validate the resolved app against TokenSpec.AppID on both token paths.
The TAT check runs on every call including cache hits, so a cached
token can never serve a request that resolved a different app. The
regression test drives the real DefaultAccountProvider +
DefaultTokenProvider with a config swap in between; the stubbed HTTP
client proves the check runs before any token work.
2026-07-27 10:56:12 +08:00
luozhixiong
fd9940ce5e fix(credential): surface the real keychain out-of-sync cause
The out-of-sync check in ResolveConfigFromMulti returned subtype
not_configured, which both the profile route and the config-default
route deliberately mask behind generic errors — so the precise
diagnosis (message naming the mismatch, hint naming the expected
keychain key and the fix command) never reached the user. This was the
review's F1: the typed-passthrough added earlier could not fire because
the real production error carried the excluded subtype, and the test
that claimed to cover it used a stub with a different subtype.

Classify the inconsistency as invalid_config at the source: the config
exists but is internally broken. Both routes now pass it through, which
matches what main surfaced before the profile feature.

The regression tests now drive the REAL DefaultAccountProvider against
an out-of-sync keychain ref on both routes and assert the precise
subtype, message, and hint; the stub-based test remains only as the
generic any-typed-error contract check with neutral wording. Test
fixtures also switch to recognized credential placeholders (the
previous literal tripped the deterministic gate).
2026-07-27 10:56:12 +08:00
luozhixiong
f01f7abbef chore: guard empty profile source and document credential wire fields
- buildCredentialProvider no longer records a phantom env profile source
  when no profile is selected.
- ERROR_CONTRACT.md documents the credential/identity-selection extension
  fields (missing_keys, required_any_of, profile, app_id,
  credential_source, profile_app_id/env_app_id).
- profile --help names the full auth status --json --verify form.
- Drop a stale out-of-repo spec reference from the CredentialSource
  comment.
2026-07-27 10:56:12 +08:00
luozhixiong
76ee4cd05e fix(config): pin saved-config commands to the saved default
- config show now resolves the saved default profile unconditionally: its
  own help (and the skill routing) promise "saved config, not current
  usage", but the output still followed --profile / LARKSUITE_CLI_PROFILE.
  A behavior test locks the boundary the help text claims.
- profile list dual-publishes the deprecated `active` field as an alias of
  `default` for one deprecation cycle, so external consumers reading
  `.active` keep working; it still marks the saved default, never the
  currently effective identity.
2026-07-27 10:56:12 +08:00
luozhixiong
cf2af70b98 fix(credential): surface precise causes instead of flattened selection errors
Four external-review findings on the arbitration's error contract:

- A typed resolver failure on the profile route (e.g. keychain key out of
  sync) now passes through with its own subtype and repair hint instead of
  being flattened into the generic profile_secret_invalid. Untyped failures
  and not_configured stay masked: their content is not guaranteed
  secret-free.
- A config that exists but cannot be read (permission denied, I/O error)
  now loads as invalid_config with the real cause preserved, so it can no
  longer degrade into profile_not_found / no_active_profile and misdirect
  the repair.
- Invalid LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE values are
  classified at the env provider (BlockReasonInvalidPolicy) and translated
  into a typed validation error carrying param and a repair hint (exit 2),
  instead of falling through to the internal-error envelope (exit 5).
- execute now refuses a profile account whose app_id differs from the
  arbitration snapshot, closing the read-twice race window with a
  deterministic error instead of silently using unchecked credentials.

Also documents that identityInputs.directKeys describes only the builtin
process-env credential surface, and makes the AccountKind switch
exhaustive.
2026-07-27 10:56:12 +08:00
luozhixiong
7b32b22069 docs(lark-shared): restore the trigger description to main's version
The description broadening to profile-selection triggers exceeded the
routing fix's actual scope: the observed failure was command selection
after the skill was already engaged, not skill discovery. Routing
guidance stays in the body's profile-selection section and command
help; the frontmatter matches main again.
2026-07-27 10:56:12 +08:00
luozhixiong
0c8bf43023 test: use recognized credential placeholders 2026-07-27 10:56:12 +08:00
luozhixiong
ce00fdcce4 fix(credential): make profile arbitration explicit 2026-07-27 10:56:12 +08:00
luozhixiong
7cc881c7a3 fix(credential): surface malformed config on the config-default path
The config-default (no-profile) path loaded config via LoadMultiAppConfig
and coerced any error into NotConfiguredError, so a malformed config was
misreported as no_active_profile ("run config init") — risking overwrite
of a recoverable-but-broken config. Route both the explicit-profile and
config-default paths through core.LoadOrNotConfigured so a malformed config
consistently surfaces as invalid_config (exit 3, cause preserved), while a
genuinely absent config keeps the friendly profile_not_found / no_active_profile.
2026-07-27 10:56:12 +08:00
luozhixiong
a340417767 docs(lark-shared): write profile-selection guidance in Chinese
Match the rest of the skill document, which is in Chinese. Keep command
and env-var tokens in English. Content unchanged.
2026-07-27 10:56:12 +08:00
luozhixiong
ccc6dd3e47 feat(profile): distinguish saved default from effective identity
profile list / config show only report the saved default profile, not the
app/profile a specific invocation actually resolves to (especially under
--profile or LARKSUITE_CLI_PROFILE). Rename the misleading profile list
JSON field active -> default (it is the configured default, not the one in
effect), and point config show / profile list / profile help at
lark-cli whoami --json for the identity actually used now. Restructure the
lark-shared skill profile guidance as an intent -> command table.
2026-07-27 10:56:12 +08:00
luozhixiong
beeeb71cd6 fix(credential): propagate malformed-config error for explicit profile
When an explicit profile was requested and LoadMultiAppConfig failed, the
error was discarded and every failure reported as profile_not_found,
masking a real config problem (e.g. malformed file) behind a misleading
"run profile list" hint. Propagate the underlying error when it is a
malformed-config failure (errors.Is ErrMalformedConfig) so errors.Is /
errors.Unwrap keep working, mirroring the no-profile branch. An absent
config is not malformed and still yields the friendly profile_not_found.
2026-07-27 10:56:12 +08:00
luozhixiong
7c04a1a60e docs(lark-shared): refine profile-selection guidance
Clarify that --profile and LARKSUITE_CLI_PROFILE accept either a profile
name or an app_id, keep the effective-identity vs OAuth-token boundary
(whoami vs auth status --json --verify), and note not to set direct
app-credential env vars unless direct credentials are provided.
2026-07-27 10:56:12 +08:00
luozhixiong
eabc8558d5 test(credential): use recognized placeholders for secret fixtures
Replace credential-shaped literals in whoami and selection tests with
placeholder values recognized by the public-content quality gate
(test-secret / your-secret / your-password / your-access-token), so the
deterministic public-content scan does not flag test fixtures as generic
credentials. No behavioral change; the fixtures are only compared for
non-leakage and identity arbitration.
2026-07-27 10:56:12 +08:00
luozhixiong
9cad3ca0e1 docs: require auth status --json --verify for login/token checks 2026-07-27 10:56:12 +08:00
luozhixiong
4306f41060 docs: point to auth status --json --verify for token validity checks 2026-07-27 10:56:12 +08:00
luozhixiong
d8f877f60f docs: guide per-command LARKSUITE_CLI_PROFILE prefix for non-persistent shells 2026-07-27 10:56:12 +08:00
luozhixiong
6aa8dec967 feat: distinguish auth status from whoami identity in help and skill 2026-07-27 10:56:12 +08:00
luozhixiong
70b611ab2c docs: use imperative one-line whoami vs auth status routing boundary 2026-07-27 10:56:12 +08:00
luozhixiong
4eb068fc20 docs: clarify whoami vs auth status boundary in lark-shared profile rule 2026-07-27 10:56:12 +08:00
luozhixiong
417777ff58 docs: trim lark-shared profile rule to lark-cli scope, drop agent-shell env mechanics 2026-07-27 10:56:12 +08:00
luozhixiong
5a0f022a97 docs: broaden lark-shared trigger to profile selection and add session-env persistence guidance 2026-07-27 10:56:12 +08:00
luozhixiong
fe3d94935a docs: forbid hollow identity promises in lark-shared profile hint 2026-07-27 10:56:12 +08:00
luozhixiong
71521b967d fix(credential): add credential_source to config errors and report profile_secret_invalid for broken default secret 2026-07-27 10:56:12 +08:00
luozhixiong
1d9f48b62f docs: add profile selection entry hint to lark-shared skill 2026-07-27 10:56:12 +08:00
luozhixiong
c4f50226a4 docs: add profile selection help to profile and whoami commands 2026-07-27 10:56:11 +08:00
luozhixiong
9b1f3fa01c refactor: drop whoami suggestion field and IdentitySelection.Suggestion
whoami reports facts about the effective identity; it should not
proactively push profile-switching guidance at agents. That guidance
lives in `profile --help` / the lark-shared skill, and failure recovery
already lives in error hints. Remove the now-unused Suggestion field
from IdentitySelection and its only setter/consumer.
2026-07-27 10:56:11 +08:00
luozhixiong
866636563e feat: surface credentialSource and directCredentialEnv in whoami 2026-07-27 10:56:11 +08:00
luozhixiong
c24a38289a test(credential): lock profile_secret_invalid against secret-bearing cause
Add a case where the underlying account-resolution error itself contains a
secret marker, proving doResolveAccount's drop-the-cause design (§5.1) holds
beyond the existing noop-keychain (empty-error) test, including across the
full errors.Unwrap chain.
2026-07-27 10:56:11 +08:00
luozhixiong
e6466b9b14 fix(credential): gate success-account direct-credential treatment on env provider
Mirror the env-incomplete block-path guard on the success-account path so a
non-env extension provider (e.g. sidecar, Priority 0) that returns an account
wins outright instead of being misreported as a direct-credential env account.
This restores pre-diff behavior for such providers: no profile arbitration, no
spurious profile_app_credential_conflict, and DirectCredentialEnv.Present stays
false when no direct env vars are set. Env matrix states are unchanged.

Add TestSelection_NonEnvExtensionProviderWinsOverProfile as a regression guard.
2026-07-27 10:56:11 +08:00
luozhixiong
deb16ce1fb feat: unify credential selection with profile conflict detection 2026-07-27 10:56:11 +08:00
luozhixiong
b6c19f5d1d feat: add IdentitySelection type for explainable credential selection 2026-07-27 10:56:11 +08:00
luozhixiong
b1d4489657 feat: add profile selection error subtypes and machine-readable fields
Declares the 5 stable error subtypes (4 config + 1 validation) and the
ConfigError/ValidationError extension fields the profile-selection
credential core (Task 4) will produce, plus builder-chain and wire-pin
tests pinning their shape.
2026-07-27 10:56:11 +08:00
luozhixiong
18451622ea feat: add LARKSUITE_CLI_PROFILE session env with flag precedence
Add LARKSUITE_CLI_PROFILE env var and make BootstrapInvocationContext
fall back to it when --profile is empty, so downstream credential
resolution sees the correct profile. Also track whether the resolved
profile came from the flag or the env fallback via a new
InvocationContext.ProfileFromFlag field, needed by a later task to
report the correct credential source.
2026-07-27 10:56:11 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
syh-cpdsss
54ddcf490b fix: remove legacy shortcut (#1997) 2026-07-22 15:33:40 +08:00
syh-cpdsss
bb246b591f fix: issue#1935 & whiteboard shortcut reformat (#1980) 2026-07-22 14:59:49 +08:00
calendar-assistant
fc2761d16b feat(calendar): auto-add bot self as attendee and note user-only search (#1991)
When creating an event as a bot, resolve the bot's own open_id via
/bot/v3/info and add it to the attendee list, mirroring how a user is
auto-joined to their own events; warn and proceed without it if the
lookup fails. Also note in the +create skill doc that the user-search
API is user-only, so resolving a name to open_id needs --as user.
2026-07-22 14:36:01 +08:00
syh-cpdsss
409a3172da feat: add okr single create shortcut & skill text opti (#1941)
* feat: add okr single create shortcut & skill text opti

* fix: deterministic-gate remove internal paging logic

* fix: CR issue

* opti: okr create/batch-create support note/category, indicator skill update
2026-07-22 14:16:54 +08:00
huarenmin13
483aadee3b fix(base): improve table shortcut behavior & guidance (#1803)
* fix(base): align table shortcut contracts

* fix(base): treat null record projection as omitted

1. Treat select_fields:null as omitted before record-get projection conflict checks.
2. Add dry-run E2E coverage for omitted and flag-projection cases.

```ai-signature
改动范围: shortcuts/base/record_ops.go 与 tests/cli_e2e/base/base_record_list_dryrun_test.go,仅调整 record-get 对 JSON null projection 的处理和回归验证
思考过程: 保持现有 projection normalizer 与互斥规则不变,只在读取 select_fields 后把 null 与缺失键等价,避免扩大到字段上限或 auto_number 行为
改动原因: PR 1803 声明 list search get 使用统一 projection contract,但 record-get 对 select_fields:null 仍返回 invalid_argument,与 record-search 不一致
Break Change: 否;仅将此前失败的 select_fields:null 输入规范化为省略,并保留 flag projection
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: b3d37c6c026f0215d994bc7c9bad4c65caee1b3bc2e9584ff20403a4d06969c3

* refactor(base): deduplicate Base dry-run E2E setup

1. Centralize Base dry-run environment setup, timeout handling, command execution,
    and exit-code assertions in runBaseDryRun.
2. Migrate record projection and field update dry-run tests without changing their contract assertio
    ns or covered scenarios.
3. Verify all 11 affected top-level tests and four projection subtests with the current-HEAD binary
    under race mode.

```ai-signature
改动范围: tests/cli_e2e/base/helpers_test.go、base_record_list_dryrun_test.go 与 base_field_update_dryrun_test.go,仅收敛 dry-run 测试执行脚手架
思考过程: 复用现有测试基础设施,把环境隔离、超时、dry-run 参数、命令执行和退出码断言集中到一个 helper,同时保留每个用例的业务断言
改动原因: PR 1803 的新增测试占主要改动量,其中 11 处重复执行模板可安全去重,降低评审体量而不削减 P1 或 P2 场景覆盖
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: ee39fef8497de65ecea1a0f22d9d87f1622c3f69daa5743ba7fd4c874dbb2ed3

---------

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
2026-07-21 23:22:48 +08:00
SunPeiYang996
e43f497650 docs: clarify fetch metadata and user cites (#1981) 2026-07-21 23:22:20 +08:00
SunPeiYang996
990d633c07 docs(skill): describe html5 block xml usage (#1380) 2026-07-21 22:26:01 +08:00
233 changed files with 16682 additions and 1479 deletions

View File

@@ -9,7 +9,40 @@ permissions:
contents: read
jobs:
goreleaser:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -26,35 +59,79 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file.
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1608,6 +1667,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.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
$(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
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
@@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
@@ -485,6 +485,18 @@ type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
type authTestAccountResolver struct {
appID string
}
func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}
func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
}
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {

View File

@@ -27,6 +27,9 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
cmd := &cobra.Command{
Use: "status",
Short: "View current auth status",
Long: `Show OAuth user login, token validity, and granted scopes.
For token-validity checks, run lark-cli auth status --json --verify.
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)

View File

@@ -4,15 +4,35 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
cmd := NewCmdAuthStatus(nil, nil)
for _, want := range []string{
"OAuth user login",
"auth status --json --verify",
"not profile/app selection diagnostics",
"lark-cli whoami",
} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
}
}
}
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
@@ -79,6 +99,51 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
}
}
type fixedStatusAccountResolver struct {
account *credential.Account
}
func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return r.account, nil
}
func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, config)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
nil,
nil,
).WithProfileFromFlag("tenant_a")
cmd := NewCmdAuth(f)
cmd.SetArgs([]string{"status", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status should use the selected built-in profile: %v", err)
}
if strings.Contains(stdout.String(), "credentials are provided externally") {
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
}
}
type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`

View File

@@ -6,8 +6,10 @@ package cmd
import (
"errors"
"io"
"os"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/spf13/pflag"
)
@@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
profileFromFlag := fs.Changed("profile")
if !profileFromFlag {
globals.Profile = os.Getenv(envvars.CliProfile)
}
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileFromFlag: profileFromFlag,
}, nil
}

View File

@@ -3,7 +3,11 @@
package cmd
import "testing"
import (
"testing"
"github.com/larksuite/cli/internal/envvars"
)
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -70,3 +74,58 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapProfileEnvFallback(t *testing.T) {
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_flag" {
t.Errorf("got %q, want tenant_flag", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("env used when flag absent", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_env" {
t.Errorf("got %q, want tenant_env", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
t.Run("empty when neither set", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
}

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
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))

View File

@@ -84,6 +84,16 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
cmd := NewCmdConfigShow(nil, nil)
if !strings.Contains(cmd.Short, "saved config") {
t.Errorf("config show short = %q, want saved config", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("config show help missing whoami route")
}
}
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -106,6 +116,77 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
}
}
// config show promises "saved config, not current usage" (help + skill
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
// change what it shows.
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Invocation.Profile = "tenant_b" // session selection must not leak in
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
}
if strings.Contains(out, `"cli_b"`) {
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
}
}
// engagedEnvStub simulates a fully engaged external credential provider.
type engagedEnvStub struct{}
func (engagedEnvStub) Name() string { return "env" }
func (engagedEnvStub) Priority() int { return 10 }
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
}
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// config show inspects the SAVED config only, so the parent command's
// external-credential gate must not apply: even with a fully engaged direct
// env credential, `config show` still answers from the saved config.
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"show"})
if err := cmd.Execute(); err != nil {
t.Fatalf("config show must bypass the external-credential gate: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
t.Fatalf("output = %s, want the saved config shown", out)
}
}
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
@@ -481,7 +562,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
// "show" is deliberately absent: it inspects the SAVED config only
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
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 {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -27,7 +27,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
cmd := &cobra.Command{
Use: "show",
Short: "Show current configuration",
Short: "Show saved config",
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
// Override parent's RequireBuiltinCredentialProvider check: this
// command reads the SAVED config only (its own help promises "saved
// config, not current usage"), so the currently effective credential
// source — external or otherwise — must not gate it.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
@@ -53,7 +62,10 @@ func configShowRun(opts *ConfigShowOptions) error {
if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
// must not change what this command shows — the help and skill routing
// promise "saved config, not current usage" (use whoami for that).
app := config.CurrentAppConfig("")
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
}

View File

@@ -110,8 +110,20 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
return nil, errors.New("backend unavailable")
}
type eventTestAccountResolver struct {
appID string
}
func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {

View File

@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,

View File

@@ -17,11 +17,14 @@ import (
)
// profileListItem is the JSON output for a single profile entry.
// `default` (formerly `active`, renamed in this feature as a declared
// breaking change) marks the saved default profile — never the identity
// effective for the current invocation; that is whoami's job.
type profileListItem struct {
Name string `json:"name"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Active bool `json:"active"`
Default bool `json:"default"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
@@ -30,7 +33,8 @@ type profileListItem struct {
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List all profiles",
Short: "List saved profiles",
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
RunE: func(cmd *cobra.Command, args []string) error {
return profileListRun(f)
},
@@ -53,7 +57,7 @@ func profileListRun(f *cmdutil.Factory) error {
return nil
}
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
currentApp := multi.CurrentAppConfig("")
currentName := ""
if currentApp != nil {
@@ -66,10 +70,10 @@ func profileListRun(f *cmdutil.Factory) error {
name := app.ProfileName()
item := profileListItem{
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Active: name == currentName,
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Default: name == currentName,
}
if len(app.Users) > 0 {

View File

@@ -14,6 +14,17 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
Long: `Profiles are named app identities managed by lark-cli.
Identity diagnostics and profile selection:
lark-cli whoami --json Show the app/profile lark-cli is using now.
lark-cli auth status --json --verify Verify OAuth login and token state.
--profile <name> Use a profile for this command only.
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
config show / profile list Inspect saved config, not current usage.
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.
A selected profile takes precedence over matching direct env credentials and tokens.`,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetTips(cmd, []string{

View File

@@ -306,14 +306,24 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
}
raw := stdout.String()
// `active` is renamed to `default` as a declared breaking change: keeping
// a permanently mirrored alias would keep misleading agents into reading
// it as the currently effective identity (whoami's job).
if strings.Contains(raw, `"active"`) {
t.Fatalf("profile list output contains renamed active field: %s", raw)
}
if !strings.Contains(raw, `"default"`) {
t.Fatalf("profile list output missing default field: %s", raw)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0].Name != "default" || !got[0].Active {
t.Fatalf("got[0] = %#v, want active default profile", got[0])
if got[0].Name != "default" || !got[0].Default {
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
}
if got[1].Name != "target" || got[1].Active {
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
if got[1].Name != "target" || got[1].Default {
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
}
}
@@ -627,6 +637,39 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
})
}
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
// per-invocation flag and session-scoped env var for selecting a profile, so
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
func TestProfileHelpHasSelectionSection(t *testing.T) {
cmd := NewCmdProfile(nil)
if !strings.Contains(cmd.Long, "Identity diagnostics and profile selection:") {
t.Errorf("profile --help missing identity diagnostics and profile selection section")
}
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile --help missing whoami identity route")
}
if !strings.Contains(cmd.Long, "config show / profile list") {
t.Errorf("profile --help missing saved-config boundary")
}
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
if !strings.Contains(cmd.Long, precedence) {
t.Errorf("profile --help missing precedence statement %q", precedence)
}
}
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
cmd := NewCmdProfileList(nil)
if !strings.Contains(cmd.Short, "saved profiles") {
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile list help missing whoami route")
}
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {

View File

@@ -10,6 +10,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
@@ -33,6 +34,15 @@ type whoamiResult struct {
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
// credential.IdentitySelection computed during resolution (not re-inferred
// here). On the non-env extension-provider path CredentialSource is
// "extension:<provider>" (e.g. "extension:sidecar"); an empty value only
// means the selection was never resolved.
CredentialSource string `json:"credentialSource"`
Explicit bool `json:"explicit"`
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
}
// delegatedUser is the user a user-identity acts on behalf of.
@@ -58,6 +68,10 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
The JSON output includes credentialSource, appId, brand, and whether direct app credential
env is present and matches the selected profile.`,
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
@@ -97,7 +111,17 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
// Read the cached selection computed during resolution; never re-infer it
// here. A resolution failure (e.g. under a non-env extension provider that
// doesn't populate a selection) degrades to the zero value rather than
// regressing whoami's own error/diagnostic path above.
var selection credential.IdentitySelection
if f.Credential != nil {
if sel, err := f.Credential.Selection(ctx); err == nil {
selection = sel
}
}
res := buildResult(cfg, as, source, diag, selection)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -122,18 +146,23 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
// selection is the cached credential.IdentitySelection from resolution; it is
// read as-is, never recomputed.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
CredentialSource: string(selection.Source),
Explicit: selection.Explicit(),
DirectCredentialEnv: selection.DirectCredentialEnv,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.

View File

@@ -15,10 +15,13 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keychain"
)
func TestResolveSource(t *testing.T) {
@@ -52,7 +55,7 @@ func TestBuildResult_UserValid(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -77,7 +80,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
if r.Available {
t.Fatalf("available = true, want false")
@@ -100,7 +103,7 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag)
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -121,7 +124,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
if r.Available {
t.Fatalf("available = true, want false")
@@ -318,3 +321,94 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
// plaintext secret, so no keychain lookup is actually required.
type noopWhoamiKeychain struct{}
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
// credentialSourceSecret is the profile secret written to config for
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
// (security: never leak a secret).
const credentialSourceSecret = "test-secret"
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
// fallback (not --profile), so Selection().Source resolves to
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
// app-credential env vars present.
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret(credentialSourceSecret),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
cred.WithProfileFromEnv("tenant_a")
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
// from the cached credential.IdentitySelection: credentialSource,
// explicit, and directCredentialEnv. whoami must read the cached selection
// as-is, not re-infer it.
func TestWhoamiIncludesCredentialSource(t *testing.T) {
f, out := profileSelectionFactory(t)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := out.String()
if strings.Contains(raw, credentialSourceSecret) {
t.Fatalf("whoami output leaked the profile secret: %s", raw)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
}
if got.CredentialSource != string(credential.SourceEnvProfile) {
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
}
if !got.Explicit {
t.Fatalf("explicit = false, want true")
}
if got.DirectCredentialEnv.Present {
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
}
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
}
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
}
}

View File

@@ -67,6 +67,17 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
Credential/identity-selection extension fields (per-Subtype-stable):
| Field | Carrier | Subtypes | Notes |
|-------|---------|----------|-------|
| `missing_keys` | `ConfigError` | `app_credential_incomplete` | env var NAMES that must all be set; never values |
| `required_any_of` | `ConfigError` | `app_credential_incomplete` | env var NAMES where any one completes the credential; mutually exclusive with `missing_keys` |
| `profile` | `ConfigError` | `profile_not_found`, `profile_secret_invalid` | requested profile name |
| `app_id` | `ConfigError` | `profile_secret_invalid` | plaintext app id; never a secret |
| `credential_source` | `ConfigError` | `profile_not_found`, `no_active_profile` | how the identity was (not) chosen: `flag:--profile` \| `env:LARKSUITE_CLI_PROFILE` \| `config` |
| `profile_app_id`, `env_app_id` | `ValidationError` | `profile_app_credential_conflict` | the two conflicting plaintext app ids |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via

View File

@@ -136,6 +136,79 @@ func TestConfigError_MarshalJSON(t *testing.T) {
}
}
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
WithRequiredAnyOf("LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN").
WithProfile("work").
WithAppID("cli_abc").
WithCredentialSource("flag:--profile")
b, err := json.Marshal(ce)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"config"`,
`"subtype":"app_credential_incomplete"`,
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
`"required_any_of":["LARKSUITE_CLI_APP_SECRET","LARKSUITE_CLI_USER_ACCESS_TOKEN"]`,
`"profile":"work"`,
`"app_id":"cli_abc"`,
`"credential_source":"flag:--profile"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset fields must not appear on the wire.
empty := NewConfigError(SubtypeProfileNotFound, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"missing_keys"`, `"required_any_of"`, `"profile"`, `"app_id"`, `"credential_source"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
b, err := json.Marshal(ve)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"validation"`,
`"subtype":"profile_app_credential_conflict"`,
`"profile_app_id":"cli_profile"`,
`"env_app_id":"cli_env"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset conflict fields must not appear on the wire.
empty := NewValidationError(SubtypeInvalidArgument, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},

View File

@@ -12,8 +12,9 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
)
// CategoryAuthentication subtypes
@@ -41,9 +42,13 @@ const (
// CategoryConfig subtypes
const (
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
)
// CategoryNetwork subtypes

View File

@@ -61,9 +61,11 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
ProfileAppID string `json:"profile_app_id,omitempty"`
EnvAppID string `json:"env_app_id,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
@@ -150,6 +152,12 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
return e
}
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
e.ProfileAppID = profileAppID
e.EnvAppID = envAppID
return e
}
// =========================== AuthenticationError =============================
// AuthenticationError is the typed error for CategoryAuthentication.
@@ -315,8 +323,18 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
// intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
Cause error `json:"-"`
Field string `json:"field,omitempty"`
MissingKeys []string `json:"missing_keys,omitempty"`
RequiredAnyOf []string `json:"required_any_of,omitempty"`
Profile string `json:"profile,omitempty"`
AppID string `json:"app_id,omitempty"`
// CredentialSource is the machine-readable App/credential selection source
// that produced this config error (e.g. "flag:--profile",
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
// profile_not_found and no_active_profile so an agent can branch
// on how the identity was (or was not) chosen. It is never a secret.
CredentialSource string `json:"credential_source,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
@@ -370,6 +388,34 @@ func (e *ConfigError) WithField(field string) *ConfigError {
return e
}
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
e.MissingKeys = slices.Clone(keys)
return e
}
func (e *ConfigError) WithRequiredAnyOf(keys ...string) *ConfigError {
e.RequiredAnyOf = slices.Clone(keys)
return e
}
func (e *ConfigError) WithProfile(name string) *ConfigError {
e.Profile = name
return e
}
func (e *ConfigError) WithAppID(appID string) *ConfigError {
e.AppID = appID
return e
}
// WithCredentialSource records the machine-readable credential-selection source
// on the wire (snake_case credential_source). The value is an enum string
// (e.g. "flag:--profile", "config"), never a secret.
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
e.CredentialSource = source
return e
}
func (e *ConfigError) WithCause(cause error) *ConfigError {
e.Cause = cause
return e

View File

@@ -643,3 +643,29 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
}
})
}
// ======================= Profile selection error subtypes =======================
func TestConfigErrorProfileFields(t *testing.T) {
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID").
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
p, ok := errs.ProblemOf(e)
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
t.Fatalf("subtype mismatch: %+v", p)
}
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
t.Errorf("missing_keys not set: %v", e.MissingKeys)
}
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
t.Errorf("credential_source not set: %q", e.CredentialSource)
}
}
func TestValidationErrorProfileConflict(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
}
}

View File

@@ -23,63 +23,89 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
appSecret := os.Getenv(envvars.CliAppSecret)
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
case hasTAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
default:
return nil, nil
}
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
if len(presentKeys) == 0 {
return nil, nil
}
if appID == "" {
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, &credential.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
// Identity policy variables are validated whenever a direct credential
// input is present. Their errors must not be hidden by a later credential
// completeness check or profile arbitration.
defaultAs := credential.Identity(os.Getenv(envvars.CliDefaultAs))
switch defaultAs {
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}
}
// Explicit strict mode policy takes priority
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
strictMode := os.Getenv(envvars.CliStrictMode)
var supported credential.IdentitySupport
switch strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
supported = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
supported = credential.SupportsUser
case "off":
acct.SupportedIdentities = credential.SupportsAll
supported = credential.SupportsAll
case "":
// Infer from available tokens
if hasUAT {
acct.SupportedIdentities |= credential.SupportsUser
supported |= credential.SupportsUser
}
if hasTAT {
acct.SupportedIdentities |= credential.SupportsBot
supported |= credential.SupportsBot
}
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliStrictMode,
}
}
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, incompleteCredentialError(
appID,
envvars.CliUserAccessToken+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
case hasTAT:
return nil, incompleteCredentialError(
appID,
envvars.CliTenantAccessToken+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
}
}
if appID == "" {
return nil, incompleteCredentialError(
appID,
envvars.CliAppSecret+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, incompleteCredentialError(
appID,
envvars.CliAppID+" is set but no app secret or access token is available",
nil,
[]string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
presentKeys)
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{
AppID: appID,
AppSecret: appSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: supported,
Kind: credential.AccountDirect,
}
if acct.DefaultAs == "" {
switch {
case hasUAT:
@@ -92,6 +118,35 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return acct, nil
}
func incompleteCredentialError(appID, reason string, missingKeys, requiredAnyOf, presentKeys []string) *credential.BlockError {
return &credential.BlockError{
Provider: "env",
Reason: reason,
Code: credential.BlockReasonCredentialIncomplete,
MissingKeys: missingKeys,
RequiredAnyOf: requiredAnyOf,
PresentKeys: presentKeys,
AppID: appID,
}
}
func presentCredentialEnvKeys(appID, appSecret string, hasUAT, hasTAT bool) []string {
var keys []string
if appID != "" {
keys = append(keys, envvars.CliAppID)
}
if appSecret != "" {
keys = append(keys, envvars.CliAppSecret)
}
if hasUAT {
keys = append(keys, envvars.CliUserAccessToken)
}
if hasTAT {
keys = append(keys, envvars.CliTenantAccessToken)
}
return keys
}
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
var envKey string
switch req.Type {

View File

@@ -6,6 +6,7 @@ package env
import (
"context"
"errors"
"slices"
"strings"
"testing"
@@ -47,6 +48,22 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonCredentialIncomplete)
}
want := []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken}
if !slices.Equal(blockErr.RequiredAnyOf, want) {
t.Fatalf("RequiredAnyOf = %v, want %v", blockErr.RequiredAnyOf, want)
}
if len(blockErr.MissingKeys) != 0 {
t.Fatalf("MissingKeys = %v, want empty", blockErr.MissingKeys)
}
if !slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppID}) {
t.Fatalf("PresentKeys = %v, want [%s]", blockErr.PresentKeys, envvars.CliAppID)
}
if blockErr.AppID != "cli_test" {
t.Fatalf("AppID = %q, want cli_test", blockErr.AppID)
}
}
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
@@ -75,18 +92,81 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
!slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppSecret}) {
t.Fatalf("BlockError = %+v, want incomplete with missing APP_ID and present APP_SECRET", blockErr)
}
if len(blockErr.RequiredAnyOf) != 0 {
t.Fatalf("RequiredAnyOf = %v, want empty for APP_SECRET-only", blockErr.RequiredAnyOf)
}
}
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
t.Setenv(envvars.CliUserAccessToken, "uat_test")
for _, tt := range []struct {
name string
key string
}{
{name: "UAT", key: envvars.CliUserAccessToken},
{name: "TAT", key: envvars.CliTenantAccessToken},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(tt.key, "token_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
!slices.Equal(blockErr.PresentKeys, []string{tt.key}) {
t.Fatalf("BlockError = %+v, want incomplete for %s", blockErr, tt.key)
}
if len(blockErr.RequiredAnyOf) != 0 {
t.Fatalf("RequiredAnyOf = %v, want empty for %s-only", blockErr.RequiredAnyOf, tt.name)
}
})
}
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
func TestResolveAccount_InvalidPolicyRejectedBeforeIncomplete(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "DEFAULT_AS", key: envvars.CliDefaultAs},
{name: "STRICT_MODE", key: envvars.CliStrictMode},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(tt.key, "banana")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("error = %T %v, want BlockError", err, err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
}
if blockErr.Param != tt.key {
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
}
if !strings.Contains(blockErr.Reason, tt.key) {
t.Fatalf("reason = %q, want %s", blockErr.Reason, tt.key)
}
})
}
}
@@ -258,6 +338,9 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliStrictMode {
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliStrictMode)
}
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
}
@@ -276,6 +359,9 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliDefaultAs {
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliDefaultAs)
}
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
}

View File

@@ -77,6 +77,8 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}
}
@@ -92,6 +94,8 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliStrictMode,
}
}

View File

@@ -7,7 +7,9 @@ package sidecar
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -146,6 +148,57 @@ func TestResolveAccount_StrictMode(t *testing.T) {
}
}
func TestResolveAccount_InvalidPolicyClassified(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
name string
key string
value string
supportedText string
}{
{
name: "default as",
key: envvars.CliDefaultAs,
value: "banana",
supportedText: "want user, bot, or auto",
},
{
name: "strict mode",
key: envvars.CliStrictMode,
value: "banana",
supportedText: "want bot, user, or off",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
setEnv(t, tt.key, tt.value)
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("error = %T %v, want BlockError", err, err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
}
if blockErr.Param != tt.key {
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
}
if !strings.Contains(blockErr.Reason, tt.key) ||
!strings.Contains(blockErr.Reason, tt.value) ||
!strings.Contains(blockErr.Reason, tt.supportedText) {
t.Fatalf("Reason = %q, want variable, invalid value, and supported values", blockErr.Reason)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)

View File

@@ -44,6 +44,27 @@ func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
// BotOnly returns true if only bot identity is supported.
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
// AccountKind declares how an account participates in credential arbitration.
type AccountKind int
const (
// AccountManaged means the provider owns the whole identity; winning it
// ends arbitration outright. The zero value, so existing providers are
// unchanged.
AccountManaged AccountKind = iota
// AccountDirect marks an actively supplied raw credential (the env
// provider's LARKSUITE_CLI_* variables). It participates in profile
// arbitration and conflict detection instead of winning outright.
//
// RESERVED: only the builtin env provider may declare AccountDirect
// today — the arbitration's direct-credential diagnostics are defined in
// terms of the process environment, and the caller rejects AccountDirect
// from any other provider. Third-party providers must return
// AccountManaged until the SPI carries provider-reported input
// descriptors.
AccountDirect
)
// Account holds resolved app credentials and configuration.
type Account struct {
AppID string
@@ -53,6 +74,7 @@ type Account struct {
ProfileName string
OpenID string // optional; if UAT is available, API result takes precedence
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
Kind AccountKind // AccountManaged (default) or AccountDirect
}
// Token holds a resolved access token and optional metadata.
@@ -76,11 +98,38 @@ type TokenSpec struct {
AppID string
}
// BlockReason classifies provider-originated block conditions that callers may
// safely map to a more specific public error contract.
type BlockReason string
const (
// BlockReasonCredentialIncomplete marks incomplete inputs from the builtin
// process-env credential provider. It is reserved for that provider because
// direct-credential arbitration and diagnostics currently name the fixed
// LARKSUITE_CLI_* env surface. Third-party providers must return an
// unclassified BlockError until the SPI carries provider-owned input
// descriptors. Blocks without a Code propagate unchanged.
BlockReasonCredentialIncomplete BlockReason = "credential_incomplete"
// BlockReasonInvalidPolicy marks a user-supplied policy input (e.g.
// LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE) that failed
// validation. The caller maps it to a typed validation error carrying
// Param and a repair hint, so user input mistakes never surface as
// internal errors.
BlockReasonInvalidPolicy BlockReason = "invalid_policy"
)
// BlockError is returned by a Provider to actively reject a request
// and prevent subsequent providers in the chain from being consulted.
type BlockError struct {
Provider string
Reason string
Provider string
Reason string
Code BlockReason
MissingKeys []string // environment variable names only; never values
RequiredAnyOf []string // environment variable names only; never values
PresentKeys []string // environment variable names only; never values
AppID string // plaintext app identifier used only for source comparison; never a secret
Param string // name of the invalid input variable on invalid_policy blocks; never a value
}
func (e *BlockError) Error() string {

View File

@@ -48,6 +48,18 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
return &credential.TokenResult{Token: "test-token"}, nil
}
type clientTestAccountResolver struct {
appID string
}
func (r clientTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}
func newClientTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, clientTestAccountResolver{appID: appID}, tokenResolver, nil)
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
@@ -58,7 +70,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpClient),
)
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
return &APIClient{
SDK: sdk,
@@ -463,7 +475,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -498,7 +510,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -532,7 +544,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -572,7 +584,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -612,7 +624,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}

View File

@@ -27,6 +27,11 @@ import (
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
// ProfileFromFlag is true when Profile was set via the --profile flag,
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
// (or neither was set). Downstream credential resolution uses this to
// report the correct profile source.
ProfileFromFlag bool
}
type Factory struct {

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "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
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,20 +55,22 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// 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,
ProfileFromFlag: inv.ProfileFromFlag,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +81,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +112,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +135,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +149,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +166,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
@@ -162,10 +175,11 @@ func buildSDKTransport() http.RoundTripper {
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
ProfileFromFlag bool
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
@@ -178,5 +192,13 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
cred := credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
if deps.Profile == "" {
// No profile selected — don't record a phantom env source.
return cred
}
if deps.ProfileFromFlag {
return cred.WithProfileFromFlag(deps.Profile)
}
return cred.WithProfileFromEnv(deps.Profile)
}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
@@ -405,6 +406,14 @@ type stubExtProvider struct {
err error
}
type stubDefaultAccountResolver struct {
acct *credential.Account
}
func (s *stubDefaultAccountResolver) ResolveAccount(_ context.Context) (*credential.Account, error) {
return s.acct, nil
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
@@ -448,6 +457,86 @@ func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
}
}
func TestRequireBuiltinCredentialProvider_AllowsMatchingAppIDOnlyProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&stubDefaultAccountResolver{acct: &credential.Account{AppID: "cli_a", AppSecret: "test-secret"}},
nil,
nil,
).WithProfileFromFlag("tenant_a")
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
if err := f.RequireBuiltinCredentialProvider(context.Background(), "auth"); err != nil {
t.Fatalf("matching APP_ID-only profile should use builtin credentials: %v", err)
}
}
// A stale LARKSUITE_CLI_PROFILE (profile that cannot resolve) must not lock
// the user out of the builtin setup/repair commands this gate guards: the
// probe falls back to provider engagement and lets the command run.
func TestRequireBuiltinCredentialProvider_StaleProfileDoesNotLockOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // no config -> "ghost" cannot resolve
stub := &stubExtProvider{name: "env"} // not engaged: returns nil, nil
cred := credential.NewCredentialProvider(
[]extcred.Provider{stub},
&stubDefaultAccountResolver{},
nil,
nil,
).WithProfileFromEnv("ghost")
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
if err := f.RequireBuiltinCredentialProvider(context.Background(), "config"); err != nil {
t.Fatalf("stale profile must not lock out builtin auth/config commands: %v", err)
}
}
// An invalid policy variable (e.g. LARKSUITE_CLI_DEFAULT_AS=banana) is a user
// input error, not an external credential takeover: the gate surfaces the
// same typed validation error as formal arbitration instead of a misleading
// "provided externally" refusal.
func TestRequireBuiltinCredentialProvider_InvalidPolicySurfacesTypedError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubExtProvider{name: "env", err: &extcred.BlockError{
Provider: "env",
Reason: "invalid LARKSUITE_CLI_DEFAULT_AS \"banana\" (want user, bot, or auto)",
Code: extcred.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, &stubDefaultAccountResolver{}, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("err = %v, want typed invalid_argument (same as formal arbitration)", err)
}
if strings.Contains(err.Error(), "provided externally") {
t.Fatalf("err = %v, must not read as external takeover", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
@@ -248,7 +255,11 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
// invalid_config, not not_configured: the config exists but is
// internally inconsistent. not_configured would let callers degrade
// this into a generic "secret invalid" answer and destroy the precise
// repair hint (which names the expected keychain key — never a value).
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -36,16 +36,13 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
if errors.Is(err, os.ErrNotExist) {
return nil, NotConfiguredError()
}
// Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. A malformed file is
// invalid_config; anything else (permission denied, etc.) is
// not_configured. Both stay on the typed structured-envelope path
// at the root command's error sink.
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
// Surface the real cause so the user can fix the broken file. Every
// non-ENOENT load failure — malformed JSON, permission denied, I/O
// error — means a config EXISTS but cannot be used: invalid_config.
// Only a genuinely absent config is not_configured; anything else
// classified as not_configured would let callers degrade it into
// profile_not_found / no_active_profile and hide the real cause.
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError()

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package credential_test
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
sidecarprovider "github.com/larksuite/cli/extension/credential/sidecar"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/sidecar"
)
func newRealSidecarCredentialProvider(t *testing.T) *credential.CredentialProvider {
t.Helper()
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv(envvars.CliProxyKey, "test-key")
t.Setenv(envvars.CliAppID, "cli_sidecar")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return credential.NewCredentialProvider(
[]extcred.Provider{&sidecarprovider.Provider{}},
nil,
nil,
nil,
)
}
func TestAuthSidecarInvalidPolicyUsesValidationContract(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "default as", key: envvars.CliDefaultAs},
{name: "strict mode", key: envvars.CliStrictMode},
} {
t.Run(tt.name, func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
t.Setenv(tt.key, "banana")
_, err := cp.ResolveAccount(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed validation error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Param != tt.key {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.key)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(problem.Hint, tt.key) {
t.Fatalf("hint = %q, want variable name %s", problem.Hint, tt.key)
}
var blockErr *extcred.BlockError
if !errors.As(err, &blockErr) ||
blockErr.Code != extcred.BlockReasonInvalidPolicy ||
blockErr.Param != tt.key {
t.Fatalf("cause = %T %v, want classified BlockError for %s", err, err, tt.key)
}
})
}
}
func TestAuthSidecarGateProbeUsesValidationContract(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
t.Setenv(envvars.CliStrictMode, "banana")
name, err := cp.ActiveExtensionProviderName(context.Background())
if name != "" {
t.Fatalf("provider name = %q, want empty on invalid policy", name)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed validation error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeInvalidArgument ||
validationErr.Param != envvars.CliStrictMode {
t.Fatalf("problem = %+v param = %q, want validation/invalid_argument param %s", problem, validationErr.Param, envvars.CliStrictMode)
}
}
func TestAuthSidecarTokenHonorsSelectedAppID(t *testing.T) {
t.Run("matching app returns sentinel", func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: "cli_sidecar",
})
if err != nil {
t.Fatalf("ResolveToken: %v", err)
}
if result == nil || result.Token != sidecar.SentinelUAT {
t.Fatalf("result = %+v, want sidecar UAT sentinel", result)
}
})
for _, tt := range []struct {
name string
appID string
}{
{name: "empty app id", appID: ""},
{name: "conflicting app id", appID: "cli_other"},
} {
t.Run(tt.name, func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: tt.appID,
})
if result != nil {
t.Fatalf("result = %+v, want no sidecar sentinel", result)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed internal error", err, err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
}
if strings.Contains(err.Error(), sidecar.SentinelUAT) {
t.Fatalf("error leaked sidecar sentinel: %v", err)
}
})
}
}

View File

@@ -9,11 +9,17 @@ import (
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"sync"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// DefaultAccountResolver is implemented by the default account provider.
@@ -136,10 +142,21 @@ type CredentialProvider struct {
httpClient func() (*http.Client, error)
warnOut io.Writer
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE);
// profileSrc records which of the two supplied it, for the reported
// selection and error attribution.
profile string
profileSrc CredentialSourceKind
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
// selection is the explainable credential-selection result, populated by
// doResolveAccount under accountOnce. It never carries a secret.
selection IdentitySelection
enrichOnce sync.Once
hintOnce sync.Once
hint *IdentityHint
@@ -161,49 +178,521 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
return p
}
// WithProfileFromFlag records the --profile flag value as the active profile.
// It governs credential arbitration and the reported selection source.
func (p *CredentialProvider) WithProfileFromFlag(profile string) *CredentialProvider {
p.profile = profile
p.profileSrc = SourceFlagProfile
return p
}
// WithProfileFromEnv records the LARKSUITE_CLI_PROFILE env fallback as the
// active profile. It governs credential arbitration and the reported
// selection source.
func (p *CredentialProvider) WithProfileFromEnv(profile string) *CredentialProvider {
p.profile = profile
p.profileSrc = SourceEnvProfile
return p
}
// ResolveAccount resolves app credentials. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
// Subsequent calls return the cached result regardless of their context.
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
acct, err := p.resolveAccountSelection(ctx)
if err != nil || acct == nil {
return acct, err
}
if _, ok := p.selectedSource.(extensionTokenSource); ok {
p.enrichOnce.Do(func() {
p.enrichOrClearIdentity(ctx, acct, p.selectedSource)
})
}
return acct, nil
}
// resolveAccountSelection performs and caches only credential selection. It
// deliberately does not resolve tokens or user_info, so callers can validate
// the selected app before any token work begins.
func (p *CredentialProvider) resolveAccountSelection(ctx context.Context) (*Account, error) {
p.accountOnce.Do(func() {
p.account, p.accountErr = p.doResolveAccount(ctx)
})
return p.account, p.accountErr
}
// doResolveAccount arbitrates the credential/App selection in three phases:
// gather all arbitration inputs in a single I/O pass, decide the route with a
// pure function, then execute the remaining I/O for the chosen route.
//
// Resolution order (encoded in decideIdentity): a managed extension provider
// (e.g. sidecar) wins outright; then an explicit profile (--profile /
// LARKSUITE_CLI_PROFILE) arbitrates against the direct env credential
// (matching app_id → profile supplies credential and tokens; mismatch → hard
// conflict; incomplete env without a usable app_id → repair error); then a
// complete direct env credential; then the config default (currentApp →
// firstApp).
//
// It populates p.selection (never carries a secret) and p.selectedSource on
// every success path.
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
in, err := p.gatherIdentityInputs(ctx)
if err != nil {
return nil, err
}
d, err := decideIdentity(in)
if err != nil {
return nil, err
}
acct, source, err := p.execute(ctx, d, in)
if err != nil {
return nil, err
}
p.selectedSource = source
// Assigned only after full success: error paths can never leave a
// partial selection behind.
p.selection = d.selection
return acct, nil
}
// providerAccount pairs an extension-provider account with its token source.
type providerAccount struct {
acct *Account
source extensionTokenSource
}
// identityInputs is one invocation's complete arbitration input, gathered in
// a single pass by gatherIdentityInputs. It is read-only after gathering;
// decideIdentity consumes it without further I/O.
type identityInputs struct {
profile string
profileSrc CredentialSourceKind
managed *providerAccount // managed extension account; wins arbitration outright
direct *providerAccount // complete direct env credential
// directBlock is a provider's explicit incomplete-direct-credential
// classification (BlockError.Code == credential_incomplete). It
// participates in profile arbitration instead of failing outright.
directBlock *extcred.BlockError
// directKeys / conflictKeys describe the BUILTIN process-env direct
// credential surface (LARKSUITE_CLI_* variable NAMES, never values).
// They annotate DirectCredentialEnv and conflict hints; a third-party
// AccountDirect provider reports its own inputs via BlockError metadata
// (PresentKeys/AppID), not through these.
directKeys []string
conflictKeys []string
config *core.MultiAppConfig
configErr error
}
// gatherIdentityInputs performs the arbitration's read phase: it consults the
// extension providers and snapshots the config. Providers classify their own
// failures at the source (BlockError.Code); this layer must not infer them by
// re-reading environment variables or parsing Reason.
func (p *CredentialProvider) gatherIdentityInputs(ctx context.Context) (identityInputs, error) {
in := identityInputs{
profile: p.profile,
profileSrc: p.profileSrc,
directKeys: presentDirectCredentialKeys(),
conflictKeys: presentDirectCredentialInputKeys(),
}
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
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)
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
switch blockErr.Code {
case extcred.BlockReasonCredentialIncomplete:
// app_credential_incomplete, profile matching, and
// DirectCredentialEnv diagnostics are defined in terms of
// the builtin LARKSUITE_CLI_* env surface. Until the SPI
// carries provider-owned input descriptors, accepting this
// classification from another provider would produce
// contradictory arbitration and repair hints.
if _, builtin := prov.(*envprovider.Provider); !builtin {
return in, newCredentialIncompleteProviderContractError(prov)
}
in.directBlock = blockErr
case extcred.BlockReasonInvalidPolicy:
// A user-supplied policy value failed validation; that is
// a validation error, never an internal one.
return in, newInvalidPolicyError(blockErr)
default:
// Blocks without a recognized Code preserve their
// original attribution.
return in, 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 = ""
break
}
p.selectedSource = source
return internal, nil
// Any other provider error preserves its original attribution.
return in, err
}
if acct == nil {
continue
}
pa := &providerAccount{acct: convertAccount(acct), source: extensionTokenSource{provider: prov}}
switch acct.Kind {
case extcred.AccountDirect:
// The arbitration's direct-credential surface — DirectCredentialEnv,
// the env:LARKSUITE_CLI_APP_ID selection source, conflict-hint
// keys — is defined in terms of the builtin process-env variables.
// Until the SPI carries provider-reported input descriptors, only
// the builtin env provider may declare AccountDirect; accepting it
// from anyone else would produce self-contradictory diagnostics
// (e.g. credentialSource "env:LARKSUITE_CLI_APP_ID" with
// directCredentialEnv.present=false). The check is by concrete
// type: the registry reserves neither names nor uniqueness, so a
// Name() comparison would be forgeable.
if _, builtin := prov.(*envprovider.Provider); !builtin {
return in, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q declared AccountDirect, which is reserved for the builtin env provider", prov.Name())
}
in.direct = pa
case extcred.AccountManaged:
in.managed = pa
default:
return in, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q returned unknown AccountKind %d", prov.Name(), acct.Kind)
}
break // the first engaged provider ends the scan (registry priority order)
}
// The config snapshot backs profile lookup, the config-default route, and
// config-default failure attribution. A winning managed or direct-env
// identity without a profile never needs it — and managed identities must
// keep working when the config is absent or malformed.
if in.managed == nil && (in.profile != "" || in.direct == nil) {
in.config, in.configErr = core.LoadOrNotConfigured()
}
return in, nil
}
// credentialRoute names which source serves the selected account and tokens.
type credentialRoute int
const (
routeManaged credentialRoute = iota
routeProfile
routeDirectEnv
routeConfigDefault
)
// decision is decideIdentity's complete verdict. Nothing in it touched I/O.
type decision struct {
route credentialRoute
selection IdentitySelection
// profileAppID is set on routeProfile; app_id is plaintext and safe to
// echo in the secret-invalid error.
profileAppID string
}
// decideIdentity holds every selection rule in one place: precedence
// (managed > profile > direct env > config default), profile/direct-env
// conflict detection, and error attribution. It is pure — same inputs, same
// verdict — so the full selection matrix is table-testable without env vars
// or config fixtures.
func decideIdentity(in identityInputs) (decision, error) {
// DirectCredentialEnv reports the direct env vars truthfully on every
// route: Present always means "direct credential env vars are set".
directEnv := DirectCredentialEnv{Present: len(in.directKeys) > 0, Keys: in.directKeys}
if in.direct != nil {
directEnv.AppID = in.direct.acct.AppID
}
switch {
case in.managed != nil:
return decision{route: routeManaged, selection: IdentitySelection{
Source: SourceExtension(in.managed.source.Name()),
DirectCredentialEnv: directEnv,
}}, nil
case in.profile != "":
return decideProfile(in, directEnv)
case in.directBlock != nil:
return decision{}, newAppCredentialIncompleteError(in.directBlock, false)
case in.direct != nil:
return decision{route: routeDirectEnv, selection: IdentitySelection{
Source: SourceEnvAppID,
DirectCredentialEnv: directEnv,
}}, nil
default:
return decision{route: routeConfigDefault, selection: IdentitySelection{
Source: selectionSourceForDefault(in.config),
DirectCredentialEnv: directEnv,
}}, nil
}
}
// decideProfile arbitrates an explicit profile against the direct env
// credential state.
func decideProfile(in identityInputs, directEnv DirectCredentialEnv) (decision, error) {
app, err := findProfile(in)
if err != nil {
return decision{}, err
}
if in.directBlock != nil {
// APP_ID-only is sufficient to compare sources: a matching selected
// profile supplies the credential and tokens; a mismatch is the same
// hard conflict as a complete direct env. Anything less than a usable
// app_id keeps the provider's repair error, extended with the
// unset-to-use-the-profile path.
if in.directBlock.AppID == "" || !slices.Contains(in.directBlock.PresentKeys, envvars.CliAppID) {
return decision{}, newAppCredentialIncompleteError(in.directBlock, true)
}
if app.AppId != in.directBlock.AppID {
return decision{}, newProfileAppCredentialConflict(
in.profile, app.AppId, in.directBlock.AppID, in.directBlock.PresentKeys)
}
directEnv.AppID = in.directBlock.AppID
directEnv.Matched = true
}
if in.direct != nil {
// E == complete: the direct env app_id must match the profile.
if app.AppId != in.direct.acct.AppID {
return decision{}, newProfileAppCredentialConflict(
in.profile, app.AppId, in.direct.acct.AppID, in.conflictKeys)
}
directEnv.Matched = true
}
return decision{
route: routeProfile,
selection: IdentitySelection{Source: in.profileSrc, DirectCredentialEnv: directEnv},
profileAppID: app.AppId,
}, nil
}
// findProfile resolves the requested profile against the config snapshot.
// A malformed config must surface its real typed cause (invalid_config):
// reporting it as profile_not_found would send the user to `profile list`
// and hide the broken file. Only a genuinely absent config degrades to
// profile_not_found, because the profile then cannot exist anywhere. Both
// deliberately outrank an incomplete direct env: fixing the profile side is
// what makes the selected profile usable.
func findProfile(in identityInputs) (*core.AppConfig, error) {
if in.configErr != nil {
if prob, ok := errs.ProblemOf(in.configErr); !ok || prob.Subtype != errs.SubtypeNotConfigured {
return nil, in.configErr
}
}
if p.defaultAcct != nil {
if in.config != nil {
if app := in.config.FindApp(in.profile); app != nil {
return app, nil
}
}
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
"profile %q not found", in.profile).
WithProfile(in.profile).
WithCredentialSource(string(in.profileSrc)).
WithHint("run `lark-cli profile list` to see available profiles.")
}
// execute performs the remaining I/O for the decided route and returns the
// account together with its token source.
func (p *CredentialProvider) execute(ctx context.Context, d decision, in identityInputs) (*Account, credentialSource, error) {
switch d.route {
case routeManaged:
return in.managed.acct, in.managed.source, nil
case routeDirectEnv:
return in.direct.acct, in.direct.source, nil
case routeProfile:
// Resolve the profile's own (keychain-backed) credential locally.
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
// A typed failure other than not_configured carries its own
// precise, secret-free diagnosis (typed errors never embed secret
// material per the error contract) — pass it through instead of
// flattening it into the generic secret error. Untyped failures
// and a config that vanished mid-resolution stay masked: their
// content is not guaranteed secret-free.
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype != errs.SubtypeNotConfigured {
return nil, nil, err
}
return nil, nil, newProfileSecretInvalidError(in.profile, d.profileAppID)
}
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
// The resolver re-reads the config; a concurrent profile edit between
// gather and here could hand back a different app. Refuse the mismatch
// instead of silently using credentials the arbitration never checked.
if acct.AppID != d.profileAppID {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"config changed during resolution: profile %q resolved to a different app", in.profile).
WithHint("retry the command.")
}
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
default: // routeConfigDefault
if p.defaultAcct == nil {
return nil, nil, core.NotConfiguredError()
}
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, nil, translateConfigDefaultFailure(err, in.config)
}
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
}
return nil, core.NotConfiguredError()
}
// translateConfigDefaultFailure attributes a config-default failure from the
// snapshot: a default profile that EXISTS (has an app_id) but whose secret
// cannot be resolved locally is profile_secret_invalid — "identity is
// configured, its secret is broken" is more actionable than "no active
// profile". Only when there is genuinely no usable default profile do we
// report no_active_profile. Other typed failures pass through unchanged.
func translateConfigDefaultFailure(err error, multi *core.MultiAppConfig) error {
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeNotConfigured {
return err
}
if multi != nil {
if app := multi.CurrentAppConfig(""); app != nil && app.AppId != "" {
return newProfileSecretInvalidError(app.ProfileName(), app.AppId)
}
}
return errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
WithCredentialSource(noActiveProfileCredentialSource).
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
}
func newProfileAppCredentialConflict(profile, profileAppID, envAppID string, presentKeys []string) error {
err := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
"profile %q app_id does not match %s", profile, envvars.CliAppID).
WithProfileAppConflict(profileAppID, envAppID)
if len(presentKeys) > 0 {
return err.WithHint("unset %s, or select a profile whose app_id matches the environment.",
humanList(presentKeys, "and"))
}
return err.WithHint("unset the direct credential environment variables, or select a profile whose app_id matches the environment.")
}
func newAppCredentialIncompleteError(blockErr *extcred.BlockError, selectedProfileAvailable bool) *errs.ConfigError {
err := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "%s", blockErr.Reason).
WithCause(blockErr)
if len(blockErr.MissingKeys) > 0 {
err.WithMissingKeys(blockErr.MissingKeys...)
}
if len(blockErr.RequiredAnyOf) > 0 {
err.WithRequiredAnyOf(blockErr.RequiredAnyOf...)
}
hint := credentialRepairHint(blockErr)
if selectedProfileAvailable && len(blockErr.PresentKeys) > 0 {
hint += fmt.Sprintf(", or unset %s to use the selected profile", humanList(blockErr.PresentKeys, "and"))
}
return err.WithHint("%s.", hint)
}
func credentialRepairHint(blockErr *extcred.BlockError) string {
if len(blockErr.RequiredAnyOf) > 0 {
return "set " + humanList(blockErr.RequiredAnyOf, "or")
}
return "set " + humanList(blockErr.MissingKeys, "and")
}
func humanList(items []string, conjunction string) string {
switch len(items) {
case 0:
return "the missing direct credential variables"
case 1:
return items[0]
case 2:
return items[0] + " " + conjunction + " " + items[1]
default:
return strings.Join(items[:len(items)-1], ", ") + ", " + conjunction + " " + items[len(items)-1]
}
}
// newInvalidPolicyError translates a provider's invalid-policy block into the
// typed validation contract: the failed variable name travels in param, the
// repair path in the hint, and the original block stays on the cause chain.
// Reason carries only the variable name and its non-secret value.
func newInvalidPolicyError(blockErr *extcred.BlockError) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", blockErr.Reason).
WithParam(blockErr.Param).
WithCause(blockErr).
WithHint("set %s to a supported value or unset it.", blockErr.Param)
}
func newCredentialIncompleteProviderContractError(prov extcred.Provider) error {
return errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q returned credential_incomplete, which is reserved for the builtin env provider", prov.Name())
}
// newProfileSecretInvalidError is deliberately generic (SECURITY): the
// underlying cause may carry secret material, so neither it nor its message
// may reach the envelope. app_id is plaintext and safe to echo.
func newProfileSecretInvalidError(profile, appID string) error {
return errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
"profile %q credential could not be resolved locally", profile).
WithProfile(profile).
WithAppID(appID).
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
}
// enrichOrClearIdentity verifies a provider-supplied user identity via
// enrichUserInfo. Verification failure is non-fatal — SupportedIdentities
// (used for strict mode) is already set by the provider — but an unverified
// identity must not survive it: a stale OpenID would attribute calls to a
// user the token can no longer act for.
func (p *CredentialProvider) enrichOrClearIdentity(ctx context.Context, acct *Account, source credentialSource) {
err := p.enrichUserInfo(ctx, acct, source)
if err == nil {
return
}
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
acct.UserOpenId = ""
acct.UserName = ""
}
// noActiveProfileCredentialSource is the credential_source reported on the
// no_active_profile error. The error contract fixes this to the literal "config": there is
// no resolved default profile at all, so the more specific config:currentApp /
// config:firstApp source values (used on successful config-default selections)
// would be misleading. It is an enum string, never a secret.
const noActiveProfileCredentialSource = "config"
// selectionSourceForDefault reports whether the config default resolved to the
// explicit currentApp or fell back to the first app.
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
if multi != nil && multi.CurrentApp != "" {
return SourceConfigCurrentApp
}
return SourceConfigFirstApp
}
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
func presentDirectCredentialKeys() []string {
var keys []string
if os.Getenv(envvars.CliAppID) != "" {
keys = append(keys, envvars.CliAppID)
}
if os.Getenv(envvars.CliAppSecret) != "" {
keys = append(keys, envvars.CliAppSecret)
}
return keys
}
// presentDirectCredentialInputKeys returns all direct env input names that
// must be cleared together to remove a profile/app_id conflict. Values are
// never returned.
func presentDirectCredentialInputKeys() []string {
keys := presentDirectCredentialKeys()
if os.Getenv(envvars.CliUserAccessToken) != "" {
keys = append(keys, envvars.CliUserAccessToken)
}
if os.Getenv(envvars.CliTenantAccessToken) != "" {
keys = append(keys, envvars.CliTenantAccessToken)
}
return keys
}
// Selection resolves the account (once) and returns the cached, secret-free
// explanation of how the credential/App was selected. It mirrors
// selectedCredentialSource: resolve-then-return.
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
if _, err := p.ResolveAccount(ctx); err != nil {
return IdentitySelection{}, err
}
return p.selection, nil
}
// enrichUserInfo resolves user identity when extension provides a UAT.
@@ -239,17 +728,13 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
}
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
if p.selectedSource != nil {
return p.selectedSource, nil
}
if p.defaultAcct == nil {
return nil, nil
}
if _, err := p.ResolveAccount(ctx); err != nil {
if _, err := p.resolveAccountSelection(ctx); err != nil {
return nil, err
}
if p.selectedSource == nil {
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved an account without selecting a token source").
WithHint("retry the command.")
}
return p.selectedSource, nil
}
@@ -302,51 +787,88 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
// ResolveToken resolves an access token.
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
source, err := p.selectedCredentialSource(ctx)
acct, err := p.resolveAccountSelection(ctx)
if err != nil {
return nil, err
}
if source != nil {
return resolveTokenFromSource(ctx, source, req)
if acct == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved no account before %s token resolution", req.Type).
WithHint("retry the command.")
}
for _, prov := range p.providers {
source := extensionTokenSource{provider: prov}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if found {
return result, nil
}
source := p.selectedSource
if source == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved app %q without selecting a token source", acct.AppID).
WithHint("retry the command.")
}
source = defaultTokenSource{resolver: p.defaultToken}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
if req.AppID == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"TokenSpec.AppID is required for %s token resolution", req.Type).
WithHint("retry the command.")
}
if found {
return result, nil
if req.AppID != acct.AppID {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token requested for app %q but the selected account belongs to app %q", req.AppID, acct.AppID).
WithHint("retry the command.")
}
return nil, &TokenUnavailableError{Type: req.Type}
return resolveTokenFromSource(ctx, source, req)
}
// ActiveExtensionProviderName reports whether an extension provider is managing
// credentials. It probes p.providers (extension providers only, not defaultAcct)
// and returns the name of the first engaged provider.
// the credentials that actually win selection. With an explicit profile that
// resolves successfully it reuses ResolveAccount's cached arbitration result;
// otherwise it probes extension providers directly and returns the first
// engaged provider.
//
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
// *extcred.BlockError (provider configured but misconfigured — still counts as
// external). Any other error is propagated to the caller.
// external). Any other probe error is propagated to the caller.
//
// A failed profile resolution (profile not found, broken secret, malformed
// config, incomplete direct env, ...) deliberately does NOT propagate: this
// probe guards the builtin setup/repair commands (auth, config), and an
// unresolvable credential must never lock the user out of the commands that
// fix it. It falls back to the engagement probe, which answers the only
// question this function owns: is an extension provider holding credentials?
//
// Returns ("", nil) when no extension provider is active (built-in keychain path).
// Safe to call multiple times — probes providers directly without the sync.Once cache.
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
// the probe path only consults providers.
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
// With an explicit profile, report the source that actually won the same
// arbitration used by commands. A matching APP_ID-only env block is not an
// external takeover once the selected profile supplies credentials/tokens.
if p.profile != "" {
if _, err := p.ResolveAccount(ctx); err == nil {
if p.selectedSource == nil {
return "", nil
}
if _, builtin := p.selectedSource.(defaultTokenSource); builtin {
return "", nil
}
return p.selectedSource.Name(), nil
}
// Resolution failed — fall through to the engagement probe.
}
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
// Align with formal arbitration: a misconfigured policy
// variable is the same typed validation error everywhere —
// not an external takeover of the provider that reported it,
// and not license to keep scanning and blame a later
// provider instead.
if blockErr.Code == extcred.BlockReasonInvalidPolicy {
return "", newInvalidPolicyError(blockErr)
}
if blockErr.Code == extcred.BlockReasonCredentialIncomplete {
if _, builtin := prov.(*envprovider.Provider); !builtin {
return "", newCredentialIncompleteProviderContractError(prov)
}
}
name := blockErr.Provider
if name == "" {
name = prov.Name()

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
@@ -23,6 +24,7 @@ type mockExtProvider struct {
err error
accountErr error
tokenErr error
tokenCalls int
}
func (m *mockExtProvider) Name() string { return m.name }
@@ -33,6 +35,7 @@ func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account,
return m.account, m.err
}
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
m.tokenCalls++
if m.tokenErr != nil {
return nil, m.tokenErr
}
@@ -49,11 +52,13 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
}
type mockDefaultToken struct {
result *TokenResult
err error
result *TokenResult
err error
tokenCalls int
}
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
m.tokenCalls++
return m.result, m.err
}
@@ -116,35 +121,45 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
}
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
for _, sourceName := range []string{"env", "authsidecar"} {
t.Run(sourceName, func(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: sourceName,
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
})
}
}
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "skip"}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
if err != nil {
t.Fatal(err)
}
if result.Token != "default_tok" {
t.Errorf("expected default_tok, got %s", result.Token)
}
if defaultToken.tokenCalls != 1 {
t.Fatalf("default ResolveToken() calls = %d, want 1", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
@@ -159,7 +174,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
t.Fatalf("ResolveAccount() error = %v", err)
}
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
@@ -181,7 +196,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
t.Fatalf("ResolveAccount() error = %v", err)
}
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want unavailable error")
}
@@ -202,7 +217,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err == nil || err.Error() != "provider exploded" {
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
}
@@ -312,12 +327,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
cp := NewCredentialProvider(
nil,
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: ""}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
if err == nil || !strings.Contains(err.Error(), "empty token") {
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
}
@@ -410,17 +425,189 @@ func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerification
}
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{err: errors.New("config unavailable")},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
if err == nil || err.Error() != "config unavailable" {
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
}
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeExtensionIO(t *testing.T) {
tests := []struct {
name string
appID string
}{
{name: "empty app id"},
{name: "different app id", appID: "other_app"},
}
for _, tt := range tests {
for _, sourceName := range []string{"env", "authsidecar"} {
t.Run(tt.name+"/"+sourceName, func(t *testing.T) {
provider := &mockExtProvider{
name: sourceName,
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
}
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{provider},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected user_info call")
},
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
assertInternalUnknownWithRetryHint(t, err)
if provider.tokenCalls != 0 {
t.Fatalf("extension ResolveToken() calls = %d, want 0", provider.tokenCalls)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
}
})
}
}
}
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeDefaultIO(t *testing.T) {
tests := []struct {
name string
appID string
}{
{name: "empty app id"},
{name: "different app id", appID: "other_app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
assertInternalUnknownWithRetryHint(t, err)
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
})
}
}
func TestCredentialProvider_ResolveTokenRejectsNilAccountBeforeTokenIO(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "requested_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want nil account error")
}
assertInternalUnknownWithRetryHint(t, err)
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenRejectsMissingSelectedSourceWithoutFallback(t *testing.T) {
extension := &mockExtProvider{
name: "env",
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
[]extcred.Provider{extension},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken,
nil,
)
cp.account = &Account{AppID: "selected_app"}
cp.accountOnce.Do(func() {})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "selected_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want missing selected source error")
}
assertInternalUnknownWithRetryHint(t, err)
if extension.tokenCalls != 0 {
t.Fatalf("extension ResolveToken() calls = %d, want 0", extension.tokenCalls)
}
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenMatchingExtensionDoesNotEnrichIdentity(t *testing.T) {
provider := &mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected user_info call")
},
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
if result.Token != "ext_tok" {
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "ext_tok")
}
if provider.tokenCalls != 1 {
t.Fatalf("extension ResolveToken() calls = %d, want 1", provider.tokenCalls)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
}
}
func assertInternalUnknownWithRetryHint(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed internal error", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error problem = %+v, want internal/unknown", problem)
}
if problem.Hint != "retry the command." {
t.Fatalf("error hint = %q, want retry hint", problem.Hint)
}
}
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// stubDecideProvider satisfies extcred.Provider for building providerAccount
// literals; decideIdentity only ever calls Name() on it.
type stubDecideProvider struct{ name string }
func (s stubDecideProvider) Name() string { return s.name }
func (s stubDecideProvider) Priority() int { return 0 }
func (s stubDecideProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, nil
}
func (s stubDecideProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func pa(providerName, appID string) *providerAccount {
return &providerAccount{
acct: &Account{AppID: appID},
source: extensionTokenSource{provider: stubDecideProvider{name: providerName}},
}
}
func appIDOnlyBlock(appID string) *extcred.BlockError {
return &extcred.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
Code: extcred.BlockReasonCredentialIncomplete,
RequiredAnyOf: []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
PresentKeys: []string{envvars.CliAppID},
AppID: appID,
}
}
func uatOnlyBlock() *extcred.BlockError {
return &extcred.BlockError{
Provider: "env",
Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing",
Code: extcred.BlockReasonCredentialIncomplete,
MissingKeys: []string{envvars.CliAppID},
PresentKeys: []string{envvars.CliUserAccessToken},
}
}
// TestDecideIdentity exercises the selection matrix as data: decideIdentity is
// pure, so every rule (precedence, conflict detection, error attribution) is
// table-testable without env vars or config fixtures.
func TestDecideIdentity(t *testing.T) {
tenantA := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
}
noCurrent := &core.MultiAppConfig{
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
}
invalidConfigErr := errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid config format")
notConfiguredErr := core.NotConfiguredError()
cases := []struct {
name string
in identityInputs
route credentialRoute
source CredentialSourceKind
matched bool
subtype errs.Subtype // "" = success expected
}{
{
name: "managed provider wins over explicit profile",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, managed: pa("sidecar", "sidecar_app"), config: tenantA},
route: routeManaged,
source: SourceExtension("sidecar"),
},
{
name: "profile conflicts with complete direct env app_id",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, direct: pa("env", "cli_x"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
subtype: errs.SubtypeProfileAppCredentialConflict,
},
{
name: "matched complete direct env yields profile route",
in: identityInputs{profile: "tenant_a", profileSrc: SourceEnvProfile, direct: pa("env", "cli_a"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
route: routeProfile,
source: SourceEnvProfile,
matched: true,
},
{
name: "APP_ID-only block matching the profile yields profile route",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
route: routeProfile,
source: SourceFlagProfile,
matched: true,
},
{
name: "APP_ID-only block mismatching the profile is a hard conflict",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_x"), directKeys: []string{envvars.CliAppID}, config: tenantA},
subtype: errs.SubtypeProfileAppCredentialConflict,
},
{
name: "UAT-only block with a valid profile keeps the repair error",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: uatOnlyBlock(), config: tenantA},
subtype: errs.SubtypeAppCredentialIncomplete,
},
{
name: "block without profile is app_credential_incomplete",
in: identityInputs{directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}},
subtype: errs.SubtypeAppCredentialIncomplete,
},
{
name: "complete direct env without profile wins",
in: identityInputs{direct: pa("env", "cli_env"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}},
route: routeDirectEnv,
source: SourceEnvAppID,
},
{
name: "malformed config is not masked as profile_not_found",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, configErr: invalidConfigErr},
subtype: errs.SubtypeInvalidConfig,
},
{
name: "absent config degrades to profile_not_found",
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, configErr: notConfiguredErr},
subtype: errs.SubtypeProfileNotFound,
},
{
name: "profile missing from a valid config is profile_not_found even with incomplete env",
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
subtype: errs.SubtypeProfileNotFound,
},
{
name: "config default reports currentApp",
in: identityInputs{config: tenantA},
route: routeConfigDefault,
source: SourceConfigCurrentApp,
},
{
name: "config default without currentApp reports firstApp",
in: identityInputs{config: noCurrent},
route: routeConfigDefault,
source: SourceConfigFirstApp,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d, err := decideIdentity(tc.in)
if tc.subtype != "" {
if err == nil {
t.Fatalf("decideIdentity = %+v, want error subtype %q", d, tc.subtype)
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != tc.subtype {
t.Fatalf("error = %v, want subtype %q", err, tc.subtype)
}
return
}
if err != nil {
t.Fatalf("decideIdentity: %v", err)
}
if d.route != tc.route {
t.Errorf("route = %d, want %d", d.route, tc.route)
}
if d.selection.Source != tc.source {
t.Errorf("source = %q, want %q", d.selection.Source, tc.source)
}
if d.selection.DirectCredentialEnv.Matched != tc.matched {
t.Errorf("matched = %v, want %v", d.selection.DirectCredentialEnv.Matched, tc.matched)
}
})
}
}

View File

@@ -74,9 +74,13 @@ 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()
// LoadOrNotConfigured distinguishes an absent config (→ not_configured)
// from a malformed/unreadable one (→ invalid_config with cause), so a
// broken config is never masked as "run config init" — matching the
// explicit-profile path in doResolveAccount.
multi, err := core.LoadOrNotConfigured()
if err != nil {
return nil, core.NotConfiguredError()
return nil, err
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
@@ -116,6 +120,7 @@ type DefaultTokenProvider struct {
tatOnce sync.Once
tatResult *TokenResult
tatAppID string
tatErr error
}
@@ -126,21 +131,42 @@ func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient fun
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
switch req.Type {
case TokenTypeUAT:
return p.resolveUAT(ctx)
return p.resolveUAT(ctx, req)
case TokenTypeTAT:
return p.resolveTAT(ctx)
return p.resolveTAT(ctx, req)
default:
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
}
}
// checkTokenAppID refuses to hand out a token for a different app than the
// caller resolved. The token provider re-reads the config, so a concurrent
// profile edit between account resolution and token resolution could otherwise
// cross tokens between apps. TokenSpec.AppID is REQUIRED here: an empty value
// would silently disable the guarantee, so it is rejected rather than skipped.
func checkTokenAppID(req TokenSpec, resolvedAppID string) error {
if req.AppID == "" {
return errs.NewInternalError(errs.SubtypeUnknown,
"TokenSpec.AppID is required for %s token resolution", req.Type)
}
if req.AppID == resolvedAppID {
return nil
}
return errs.NewInternalError(errs.SubtypeUnknown,
"config changed during resolution: token requested for app %q but the saved profile now resolves to a different app", req.AppID).
WithHint("retry the command.")
}
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
// may be refreshed between calls and GetValidAccessToken handles its own caching.
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if err := checkTokenAppID(req, acct.AppID); err != nil {
return nil, err
}
httpClient, err := p.httpClient()
if err != nil {
return nil, err
@@ -157,20 +183,36 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// resolveTAT resolves a tenant access token. The result is cached after the first
// call via sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
})
return p.tatResult, p.tatErr
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
// resolveTAT resolves a tenant access token. The result is cached after the
// first mint via sync.Once — only the context from that call is used.
//
// The account is resolved and checked against the request BEFORE any token
// work: a mismatched request must not trigger a token mint (network call,
// quota, audit trail) for the wrong app. The cached result is additionally
// re-checked on every hit, so a token minted for one app is never served to
// a request that resolved another.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if err := checkTokenAppID(req, acct.AppID); err != nil {
return nil, err
}
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx, acct)
p.tatAppID = acct.AppID
})
if p.tatErr != nil {
return nil, p.tatErr
}
if err := checkTokenAppID(req, p.tatAppID); err != nil {
return nil, err
}
return p.tatResult, nil
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context, acct *Account) (*TokenResult, error) {
httpClient, err := p.httpClient()
if err != nil {
return nil, err

View File

@@ -4,10 +4,15 @@
package credential
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
@@ -92,3 +97,136 @@ func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
}
}
func TestCheckTokenAppID(t *testing.T) {
if err := checkTokenAppID(TokenSpec{Type: TokenTypeUAT}, "cli_a"); err == nil {
t.Fatal("empty requested app must be rejected: it would silently disable the guarantee")
}
if err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_a"); err != nil {
t.Fatalf("matching app must pass: %v", err)
}
err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_b")
if err == nil {
t.Fatal("mismatched app must be refused")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError", err)
}
}
// REAL-path regression for review F2: the token provider re-reads the config,
// so a profile edit between account resolution and token resolution must not
// hand a token minted for the new app to a caller that resolved the old one.
// Uses the real DefaultAccountProvider + DefaultTokenProvider; the HTTP stub
// makes the network step unreachable, so reaching it proves the app check ran
// and passed first.
func TestDefaultTokenProvider_RefusesTokenAfterConfigSwap(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeCfg := func(appID string) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: appID, AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
}
writeCfg("cli_a")
httpSentinel := errors.New("http client sentinel: unreachable in test")
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { return nil, httpSentinel },
nil,
)
// Matching app: the consistency check passes and resolution proceeds to
// the (stubbed) HTTP step.
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
if !errors.Is(err, httpSentinel) {
t.Fatalf("err = %v, want the HTTP sentinel (check must pass for a matching app)", err)
}
// The profile now resolves to a different app: the token request that was
// arbitrated for cli_a must be refused before any token work happens.
writeCfg("cli_b")
_, err = tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
t.Fatalf("err = %v, want config-changed refusal", err)
}
}
// F1 regression: a TAT request for a mismatched app must be refused BEFORE
// any token work starts — no HTTP client construction, no mint, no cache —
// otherwise the CLI mints (and caches) a token for the wrong app and only
// then refuses to return it, leaving auth audit/quota side effects behind.
func TestDefaultTokenProvider_TATChecksAppBeforeAnyTokenWork(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
httpCalled := false
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { httpCalled = true; return nil, errors.New("http sentinel") },
nil,
)
// The profile resolves to cli_b, but the caller arbitrated cli_a.
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"})
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
t.Fatalf("err = %v, want config-changed refusal", err)
}
if httpCalled {
t.Fatal("token work started for a mismatched app: the check must run before any HTTP client is built")
}
}
// countingTATTripper serves a canned successful TAT response and counts calls.
type countingTATTripper struct{ calls int }
func (c *countingTATTripper) RoundTrip(*http.Request) (*http.Response, error) {
c.calls++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"your-access-token"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}
// TAT happy path: the first request mints the token over HTTP, the second is
// served from the sync.Once cache without another HTTP call.
func TestDefaultTokenProvider_TATSuccessAndCacheHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
tripper := &countingTATTripper{}
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { return &http.Client{Transport: tripper}, nil },
nil,
)
req := TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"}
first, err := tp.ResolveToken(context.Background(), req)
if err != nil || first.Token != "your-access-token" {
t.Fatalf("first resolve = %+v, %v; want minted token", first, err)
}
second, err := tp.ResolveToken(context.Background(), req)
if err != nil || second.Token != "your-access-token" {
t.Fatalf("second resolve = %+v, %v; want cached token", second, err)
}
if tripper.calls != 1 {
t.Fatalf("HTTP calls = %d, want exactly 1 (second resolve must hit the cache)", tripper.calls)
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
// CredentialSourceKind is the wire-stable App/credential selection source.
type CredentialSourceKind string
const (
SourceFlagProfile CredentialSourceKind = "flag:--profile"
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
// SourceExtensionPrefix prefixes the name of a managed extension provider
// that won selection outright (e.g. "extension:sidecar"). With it, an
// empty Source is left with exactly one meaning: not resolved.
SourceExtensionPrefix CredentialSourceKind = "extension:"
)
// SourceExtension reports the selection source for a managed extension
// provider by name.
func SourceExtension(name string) CredentialSourceKind {
return SourceExtensionPrefix + CredentialSourceKind(name)
}
// DirectCredentialEnv describes the state of direct app credential env vars.
// It never carries a secret value — only names and the non-sensitive app_id.
type DirectCredentialEnv struct {
Present bool `json:"present"`
Keys []string `json:"keys,omitempty"`
AppID string `json:"appId,omitempty"`
Matched bool `json:"matched,omitempty"`
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
}
// IdentitySelection is the explainable result of credential selection.
// It carries NO secret value.
type IdentitySelection struct {
Source CredentialSourceKind
DirectCredentialEnv DirectCredentialEnv
}
// Explicit reports whether the identity was actively specified by the
// user/agent (flag or env), which governs no-fallback behavior.
func (s IdentitySelection) Explicit() bool {
switch s.Source {
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
return true
default:
return false
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "testing"
func TestIdentitySelectionExplicit(t *testing.T) {
cases := []struct {
src CredentialSourceKind
explicit bool
}{
{SourceFlagProfile, true},
{SourceEnvProfile, true},
{SourceEnvAppID, true},
{SourceConfigCurrentApp, false},
{SourceConfigFirstApp, false},
}
for _, c := range cases {
sel := IdentitySelection{Source: c.src}
if sel.Explicit() != c.explicit {
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
}
}
}

View File

@@ -52,6 +52,24 @@ func TestFullChain_EnvWins(t *testing.T) {
}
}
func TestFullChain_EnvRejectsDifferentApp(t *testing.T) {
t.Setenv(envvars.CliAppID, "env_app")
t.Setenv(envvars.CliAppSecret, "env_secret")
t.Setenv(envvars.CliUserAccessToken, "env_uat")
cp := credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
nil, nil, nil,
)
_, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "other_app",
})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
}
func TestFullChain_Fallthrough(t *testing.T) {
// env provider returns nil (no env vars set), falls through to default token
ep := &envprovider.Provider{}
@@ -59,7 +77,8 @@ func TestFullChain_Fallthrough(t *testing.T) {
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
nil, mock, nil,
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
mock, nil,
)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "app1",
@@ -72,6 +91,14 @@ func TestFullChain_Fallthrough(t *testing.T) {
}
}
type mockDefaultAccountProvider struct {
account *credential.Account
}
func (m *mockDefaultAccountProvider) ResolveAccount(context.Context) (*credential.Account, error) {
return m.account, nil
}
type mockDefaultTokenProvider struct {
token string
scopes string

View File

@@ -21,6 +21,7 @@ const (
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProfile = "LARKSUITE_CLI_PROFILE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
return "", fmt.Errorf("%s: %w", flagName, err)
}
return value, nil
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

7
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.77",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.77",
"cpu": [
"x64",
"arm64"
"arm64",
"riscv64"
],
"hasInstallScript": true,
"license": "MIT",

View File

@@ -1,12 +1,13 @@
{
"name": "@larksuite/cli",
"version": "1.0.74",
"version": "1.0.77",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js",
"release:check": "node scripts/release-preflight.js"
},
"os": [
"darwin",

View File

@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error("[SECURITY] Expected checksum is missing or invalid");
}
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
throw new Error(
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
);
}
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
);
});
it("returns null when checksums.txt does not exist", () => {
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("matches case-insensitively", () => {
it("accepts a valid uppercase 64-character hex hash", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
for (const [name, expectedHash] of [
["null", null],
["empty", ""],
["non-string", 123],
]) {
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Expected checksum is missing or invalid/);
return true;
}
);
});
}
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "abc123"),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY] format Error for a non-hex hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "g".repeat(64)),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
const observed = {
packageVersion: packageVersion ?? null,
lockVersion: lockVersion ?? null,
lockRootVersion: lockRootVersion ?? null,
tagVersion: null,
};
for (const [field, value] of [
["package.json.version", packageVersion],
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
);
}
}
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
return releaseError(
"Package version fields do not match",
observed,
"Synchronize package.json.version and both package-lock.json version fields.",
);
}
if (tag === undefined) {
return { ok: true, data: observed };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
);
}
const tagVersion = tag.slice(1);
if (tagVersion !== packageVersion) {
return releaseError(
"Tag version does not match the package version",
{ ...observed, tagVersion, tag },
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
}
function writeResult(result) {
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
}
function main() {
const args = process.argv.slice(2);
let tag;
if (args.length === 2 && args[0] === "--tag") {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
return;
}
const repoRoot = path.resolve(__dirname, "..");
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
} catch (error) {
writeResult(releaseError(
"Could not read release package metadata",
{ reason: error.message },
"Ensure package.json and package-lock.json exist and contain valid JSON.",
));
}
}
module.exports = { validateReleasePreflight };
if (require.main === module) main();

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
packageJson: { version },
packageLockJson: {
version,
packages: { "": { version } },
},
};
}
function assertRejected(result) {
assert.equal(result.ok, false);
assert.equal(result.error.type, "release_preflight");
assert.equal(typeof result.error.message, "string");
}
describe("validateReleasePreflight", () => {
it("accepts matching stable package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata();
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
{
ok: true,
data: {
packageVersion: "1.2.3",
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
});
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});

View File

@@ -3,49 +3,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
VERSION=$(node -p "require('./package.json').version")
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
CURRENT_BRANCH=$(git branch --show-current)
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
exit 1
fi
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
if ! git diff --quiet HEAD -- package.json package-lock.json; then
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
exit 1
fi
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
git fetch origin main
echo "Successfully created and pushed tag ${TAG}"
HEAD_SHA=$(git rev-parse HEAD)
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
echo "Error: HEAD must exactly match origin/main before tagging." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Error: local tag ${TAG} already exists." >&2
exit 1
fi
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
if [ -n "${REMOTE_TAG}" ]; then
echo "Error: remote tag ${TAG} already exists." >&2
exit 1
fi
git tag "${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}"
echo "Successfully pushed tag ${TAG}"

View File

@@ -12,10 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,7 +14,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
return err
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -104,6 +104,22 @@ func TestDryRunFieldOps(t *testing.T) {
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
autoNumberRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"field-id": "fld_1",
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
},
nil,
nil,
)
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
}
}
func TestDryRunRecordOps(t *testing.T) {
@@ -117,7 +133,7 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,

View File

@@ -81,6 +81,37 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
return parent.ExecuteContext(context.Background())
}
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
t.Helper()
if err == nil {
t.Fatal("expected invalid-argument validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != wantParam {
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
}
if wantParams != nil {
if len(validationErr.Params) != len(wantParams) {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
for i, want := range wantParams {
if validationErr.Params[i].Name != want {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
}
}
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
t.Fatalf("err=%v, want message containing %q", err, messageContains)
}
}
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
@@ -818,8 +849,189 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
t.Fatalf("stdout=%s", got)
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
}
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
tests := []struct {
name string
field interface{}
submitted map[string]interface{}
hintContains []string
}{
{
name: "direct complex server type overrides simple submitted type",
field: map[string]interface{}{"type": "auto_number"},
submitted: map[string]interface{}{"type": "number"},
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
},
{
name: "nested simple server type still recommends readback",
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
submitted: map[string]interface{}{"type": "auto_number"},
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
},
{
name: "submitted simple type still recommends readback when response omits type",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"type": "text"},
hintContains: []string{`type "text"`, "cannot determine the previous type"},
},
{
name: "missing type is conservative",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"name": "Amount"},
hintContains: []string{"unknown or uncommon field type", "+field-get"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
t.Fatalf("result=%#v, want readback recommendation", got)
}
hint, _ := got["verification_hint"].(string)
for _, want := range tc.hintContains {
if !strings.Contains(hint, want) {
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
}
}
})
}
}
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 800070003,
"msg": "no operation produced",
},
})
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
if err == nil {
t.Fatal("expected the API no-op response to surface as an error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed API error, got %T %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if got := stdout.String(); strings.TrimSpace(got) != "" {
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
}
}
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
},
}
reg.Register(stub)
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
gotBody := string(stub.CapturedBody)
for _, want := range []string{
`"name":"编号"`,
`"type":"auto_number"`,
`"rules":[`,
`"date_format":"yyyyMM"`,
`"length":4`,
} {
if !strings.Contains(gotBody, want) {
t.Fatalf("request body missing %q:\n%s", want, gotBody)
}
}
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
if strings.Contains(gotBody, forbidden) {
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
}
}
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{`"reformat_existing_records"`} {
if strings.Contains(got, forbidden) {
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
}
}
}
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
}
reg.Register(stub)
// Unknown v3 keys are forwarded unchanged; the server remains the source of
// truth for whether a field-update property is supported.
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
t.Fatalf("expected successful update, got: %s", got)
}
}
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
shortcut common.Shortcut
runtime *common.RuntimeContext
}{
{
name: "create",
shortcut: BaseFieldCreate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
{
name: "update",
shortcut: BaseFieldUpdate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
}
})
}
}
@@ -1091,8 +1303,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
t.Fatalf("stdout=%s", got)
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("create generated field recommends readback", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
@@ -1139,11 +1375,58 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
}
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
t.Run("create array with generated field recommends readback", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"Title"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"编号"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true || data["total"] != float64(2) {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["fields"].([]interface{}); !ok {
t.Fatalf("batch create must keep fields array: %#v", data)
}
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
}
})
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1318,6 +1601,32 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"A,B", "@Owner"},
"record_id_list": []interface{}{"rec_alias_special"},
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
"--field-names", `"A,B",@Owner`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1614,28 +1923,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
cases := []struct {
name string
args []string
wantParam string
wantParams []string
}{
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append(append([]string{}, baseArgs...), tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
}
})
}
})
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
"--field-names", "Age",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
}
})
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
cases := []struct {
name string
args []string
param string
}{
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
})
}
})
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_search"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(searchStub)
if err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("captured body=%s", body)
}
})
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
"--field-names", "Name", "--field-names", "Age", "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get", func(t *testing.T) {
@@ -1992,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "write",
Risk: "high-risk-write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -28,23 +28,16 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
if name == "field-names" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().StringArray(name, nil, "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -61,11 +54,6 @@ func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, string
_ = cmd.Flags().Set(name, value)
}
}
for name, values := range stringSliceFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -477,6 +465,40 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
}
}
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "record get", shortcut: BaseRecordGet},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup("field-id")
if primary == nil || primary.Hidden {
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
}
help := cmd.Flags().FlagUsages()
for _, aliasName := range []string{"fields", "field-names"} {
alias := cmd.Flags().Lookup(aliasName)
if alias == nil || !alias.Hidden {
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
}
if strings.Contains(help, "--"+aliasName) {
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
}
}
})
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -779,7 +801,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
},
},
{
@@ -823,9 +846,13 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"Sub-record/child-record path",
"set that link field to a parent record reference array",
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -839,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"Happy path field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"Batch create supports max 200 records per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -973,11 +1000,17 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
if strings.Contains(help, "reformat-existing-records") {
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
`Example auto_number update: lark-cli base +field-update`,
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
"just submit the target field definition and do not add extra low-level parameters",
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
@@ -990,6 +1023,9 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
if strings.Contains(tips, "--reformat-existing-records") {
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
@@ -1112,6 +1148,10 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula update validate err=%v", err)
}
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
t.Fatalf("auto number update validate err=%v", err)
}
}
func TestBaseTableValidate(t *testing.T) {
@@ -1233,13 +1273,89 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
t.Fatalf("err=%v", err)
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
map[string][]string{"field-id": {"fld_name"}},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
}
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
ctx := context.Background()
fields := make([]string, 51)
for i := range fields {
fields[i] = "Field " + strconv.Itoa(i+1)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
nil,
nil,
)); err != nil {
t.Fatalf("50 projection fields should be accepted: %v", err)
}
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
body, marshalErr := json.Marshal(map[string]interface{}{
"keyword": "Alice",
"search_fields": []string{"Name"},
"select_fields": fields,
})
if marshalErr != nil {
t.Fatalf("marshal search body: %v", marshalErr)
}
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
}
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
}, nil, nil)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("recordSearchJSONBody() error = %v", err)
}
if _, exists := body["select_fields"]; exists {
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
}
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
}
}
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
ctx := context.Background()
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
}
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
@@ -1940,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2241,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2309,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2357,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2368,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2404,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2438,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"fmt"
"strings"
"time"
@@ -36,7 +37,10 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
@@ -48,7 +52,10 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Body(body).
@@ -166,10 +173,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
fields = append(fields, data)
}
if len(fields) == 1 {
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
return nil
}
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
return nil
}
@@ -197,10 +204,101 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
return nil
}
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
// fieldCreateBatchResult attaches the same top-level readback contract to a
// multi-field create. It recommends +field-get when any submitted field is a
// computed/linked/generated (or unknown) type, so agents know when to verify
// server state without breaking the existing fields/total structure.
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
recommend := false
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
for _, body := range submitted {
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
recommend = true
reason = r
break
}
}
return attachFieldReadbackRecommendation(result, recommend, reason)
}
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
returnedType := normalizeFieldType(fieldResultType(result["field"]))
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
if returnedType != "" && submittedType != "" && returnedType != submittedType {
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
}
fieldType := returnedType
if fieldType == "" {
fieldType = submittedType
}
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
}
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
}
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
result["field_get_recommended"] = readbackRecommended
result["verification_hint"] = reason
if readbackRecommended {
result["next_step"] = "field_get"
} else {
result["next_step"] = "done"
}
return result
}
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
return fieldTypeReadbackRecommendation(fieldType, operation)
}
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
fieldType = normalizeFieldType(fieldType)
switch fieldType {
case "formula", "lookup", "auto_number", "link":
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
default:
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
}
}
func normalizeFieldType(fieldType string) string {
return strings.ToLower(strings.TrimSpace(fieldType))
}
func fieldResultType(value interface{}) string {
field, ok := value.(map[string]interface{})
if !ok {
return ""
}
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
return fieldType
}
nested, ok := field["field"].(map[string]interface{})
if !ok {
return ""
}
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
}
func executeFieldDelete(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)

View File

@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for fields with options, such as select or multi-select fields.",
"Use only for select fields, whether multiple is false or true.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",

View File

@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}

View File

@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
},
Tips: append([]string{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Happy path field: create_records is an array of independent record field maps.",
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"Batch create supports max 200 records per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},

View File

@@ -20,8 +20,9 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -44,9 +45,6 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordListFieldAlias(runtime); err != nil {
return err
}
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -61,6 +59,9 @@ var BaseRecordList = common.Shortcut{
return err
}
}
if _, err := recordProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,
@@ -72,22 +73,6 @@ var BaseRecordList = common.Shortcut{
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
return flag
}
func recordListFieldNamesAliasFlag() common.Flag {
return common.Flag{
Name: "field-names",
Type: "string_slice",
Desc: "hidden alias for --field-id; accepts comma-separated field names",
Hidden: true,
}
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -102,10 +87,3 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
if runtime.Changed("field-id") && runtime.Changed("field-names") {
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
}
return nil
}

View File

@@ -5,18 +5,21 @@ package base
import (
"context"
"errors"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
@@ -46,7 +49,6 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
@@ -69,7 +71,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
if err != nil {
return recordSelection{}, err
}
@@ -83,7 +89,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
if err != nil {
return recordSelection{}, err
}
@@ -104,20 +114,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
})
}
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
return nil, err
return nil, withValidationParam(err, projectionParam)
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
if !ok {
if !ok || rawJSONFields == nil {
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
@@ -128,18 +138,26 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
return nil, err
return nil, withValidationParam(err, "--json")
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
}
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
}
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
max: maxBatchGetSelectFieldCount,
max: max,
allowNil: true,
allowEmpty: true,
})
@@ -211,7 +229,11 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
params := url.Values{}
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
for _, field := range recordListFields(runtime) {
fields, err := recordProjectionFields(runtime)
if err != nil {
return common.NewDryRunAPI()
}
for _, field := range fields {
params.Add("field_id", field)
}
if viewID := runtime.Str("view-id"); viewID != "" {
@@ -375,11 +397,121 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
return err
}
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
func recordProjectionFieldFlag(desc string) common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = desc
return flag
}
func recordProjectionAliasFlag(name string) common.Flag {
flagType := "string_array"
if name == "field-names" {
// Preserve the original compatibility contract: --field-names uses
// pflag's CSV parser, including quoted commas, and treats @ literally.
flagType = "string_slice"
}
return runtime.StrArray("field-id")
return common.Flag{
Name: name,
Type: flagType,
Desc: "hidden alias for --field-id projection",
Hidden: true,
}
}
func recordProjectionParam(runtime *common.RuntimeContext) string {
switch {
case runtime.Changed("fields"):
return "--fields"
case runtime.Changed("field-names"):
return "--field-names"
default:
return "--field-id"
}
}
func withValidationParam(err error, param string) error {
if err == nil || param == "" {
return err
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return err
}
reason := validationErr.Error()
// The caller knows which input produced this validation error. Replace any
// params inferred from the rendered message: field values such as Cost--USD
// must not be mistaken for a --USD flag.
validationErr.Param = param
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
return err
}
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
}
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
}
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
fieldIDs := runtime.StrArray("field-id")
fieldIDsSet := runtime.Changed("field-id")
fieldsSet := runtime.Changed("fields")
fieldNamesSet := runtime.Changed("field-names")
projectionParams := make([]string, 0, 3)
if fieldIDsSet {
projectionParams = append(projectionParams, "--field-id")
}
if fieldsSet {
projectionParams = append(projectionParams, "--fields")
}
if fieldNamesSet {
projectionParams = append(projectionParams, "--field-names")
}
if len(projectionParams) > 1 {
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
for _, param := range projectionParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
WithParam(projectionParams[0]).
WithParams(invalidParams...).
WithHint("Use only --field-id for projection.")
}
if fieldsSet {
return recordProjectionAliasFields(runtime, "fields", max)
}
if fieldNamesSet {
return recordProjectionAliasFields(runtime, "field-names", max)
}
fields, err := normalizeRecordSelectFields(fieldIDs, max)
return fields, withValidationParam(err, "--field-id")
}
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
var fields []string
if flagName == "field-names" {
fields = runtime.StrSlice(flagName)
} else {
pc := newParseCtx(runtime)
values := runtime.StrArray(flagName)
fields = make([]string, 0, len(values))
for _, raw := range values {
parsed, err := parseStringListFlexible(pc, raw, flagName)
if err != nil {
return nil, withValidationParam(err, "--"+flagName)
}
fields = append(fields, parsed...)
}
}
if len(fields) == 0 {
err := baseFlagErrorf("--%s must include at least one field", flagName)
return nil, withValidationParam(err, "--"+flagName)
}
normalized, err := normalizeRecordSelectFields(fields, max)
return normalized, withValidationParam(err, "--"+flagName)
}
func executeRecordList(runtime *common.RuntimeContext) error {
@@ -392,7 +524,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
}
limit := getPaginationLimit(runtime)
params := map[string]interface{}{"offset": offset, "limit": limit}
fields := recordListFields(runtime)
fields, err := recordProjectionFields(runtime)
if err != nil {
return err
}
if len(fields) > 0 {
params["field_id"] = fields
}

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