mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
ca35f60616
* docs(skills): require explicit confirmation before apps +cache-clear
Asked to clear an app's online cache, an agent read `Risk: high-risk-write` from
--help and then supplied `--yes` itself on the first call, wiping production
cache without ever hitting the confirmation gate.
The CLI gate is fine: no --yes -> exit 10 confirmation_required, and --dry-run ->
exit 0 without triggering it. The wording was not. It only forbade appending
`--yes` *after* an exit-10, and said "已明确授权可直接带 --yes" without defining
authorization — so "clear my cache" read as authorization.
- `+cache-clear` gets a CAUTION block: never self-supply `--yes` on the first
call; without confirmation, either --dry-run or ask, then stop and wait. exit
10 is not a signal to retry with --yes.
- Add a zero-ambiguity table separating a *request* to clear ("clear the online
cache") from a *confirmation* ("我确认清 dev"), so blocking the accidental wipe
does not also kill the cases that were already correct: an explicit
confirmation still goes straight to `--yes`, and a request with no environment
named still has to ask instead of picking one.
- Note that online needs a confirmation phrase even when named explicitly.
`+cache-delete` gains the response field an agent has to read
(`deleted_key_count`): 0 means the key never existed, not "deleted
successfully", plus the get -> delete -> get chain needed to prove a delete took
effect — a single miss afterwards cannot tell the two apart.
SKILL.md: add +cache-clear to 禁止预授权判定底线, the one list a pre-authorized
run cannot skip; a reference-level rule alone would be bypassed there. The
routing table is left alone — no other row annotates risk, including
+file-delete, +role-delete and +member-remove.
* fix(apps): stop attaching request-shaped hints to precondition failures
`+db-execute` against a tenant that never activated Miaoda returns code 221800
"miaoda UAT not activated" with the hint "verify table/column names with
`+db-table-get` ... target the dev database with --environment dev". Neither step
can help: the failure is tenant-level, so a caller following the hint loops over
table lookups and env retries that fail identically.
Two causes. 221800 was unregistered, so it degraded to api/unknown — nothing in
the envelope distinguished "your tenant is not activated, stop" from "your SQL
was wrong, fix it and retry". And withAppsHint filled the caller's hint whenever
the server sent none, without looking at what failed: the hints are
command-scoped ("verify --app-id", "verify table/column names", "list releases"),
so every one of them describes the request, and the request is exactly what
failed_precondition says was fine.
Register 221800 as validation/failed_precondition (same shape as 400002465 "app
has no database yet") and gate the hint fallback on the subtype.
Blast radius is two codes, since that is all the subtype covers here:
- 221800 — now withheld; message and code still carry the meaning.
- 400002655 "no running container" — only when it reaches a non-observability
command; the observability pair rewrites it first, and "verify --app-id" was
never the fix for an undeployed app.
400002465 / 500002759 are intercepted by the isAppNoDatabaseError branch above
the gate, and 400002479 is served by withDBSyncHint, which does not delegate
here. The other 78 call sites take the original path for every input.
Gate on the one subtype, not on Category: this package asserts on purpose that an
authentication failure on +role-list (99991663) keeps the app-access hint and a
503 on credential issuance keeps the developer-access hint. Those hints are broad
enough to survive a caller-standing failure; only the precondition class is
misdescribed by construction. A test pins that, so widening the gate to Category
fails loudly instead of silently dropping those hints.
The gate is asserted on the real classification path (BuildAPIError -> the code
table -> withAppsHint), not only on a hand-built Problem. Constructing
SubtypeFailedPrecondition directly feeds the gate the input it wants and passes
whether or not 221800 is registered, so the registration itself has to be part of
what the test covers.
No recovery hint for 221800 — the activation path is a product procedure, and
guessing one is what made this failure misleading in the first place.
* fix(apps): classify file-storage and app-level failures
Five Spark business codes reached the CLI unregistered, so every one of them came
out as api/unknown with exit 1: a caller could not tell "your app id is wrong"
from "you lack permission" from "the upstream is having a bad minute", and the
exit code offered no way to branch either.
400002484 app not found -> validation/invalid_argument exit 2
400002467 no admin/developer perm -> authorization/permission_denied exit 3
500002761 ditto, pre-4xx renumber -> same
400000034 file not found/no access -> api/not_found exit 1
500000034 ditto, pre-4xx renumber -> same
400002467 is not file-specific: db commands (+db-table-list, +db-table-get,
+db-quota-get, +db-changelog-list) return it for an app the caller cannot access,
so registering it fixes both domains at once.
400002484 covers a well-formed id that does not exist AND a malformed one
("notanappid", "app_1" return it too), so the argument itself is the failure ->
invalid_argument, whose exit 2 separates "you passed the wrong id" from an
upstream fault. Environments that have not picked it up answer with 400002465
instead, conflating it with "app has no database yet"; the CLI cannot tell those
apart on the old code, so nothing here keys on that.
Both the current and the pre-4xx number are registered for each file failure.
The domain is moving its client-class errors from the 5xxxxxxxx band into
4xxxxxxxx, rolled out per environment, so both are live at once and dropping the
old one would silently return the un-migrated half to api/unknown — the same trap
that made the no-database recovery flow disappear when the server renumbered it
(see appNoDatabaseCode). The new number is not derivable from the old either:
500002761 became 400002467, tail digits included.
No hints added: permission_denied already has framework recovery wording, and a
domain-specific one would have to invent a remedy.
279 lines
14 KiB
Go
279 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
)
|
||
|
||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||
const appsService = "apps"
|
||
|
||
// apiBasePath is the registered OAPI prefix for the apps domain.
|
||
const apiBasePath = "/open-apis/spark/v1"
|
||
|
||
// appIDListHint is the shared recovery hint for commands whose most likely
|
||
// failure cause is a wrong/inaccessible --app-id. It points at +list to find
|
||
// the correct app id. The app_/cli_ format rule is taught in
|
||
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
|
||
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
|
||
|
||
// appNoDatabaseCode / appNoDatabaseLegacyCode are the Spark business codes seen
|
||
// when a db command runs against an app that has not initialized a database yet.
|
||
// The raw server message carries internal workspace terminology, so the CLI
|
||
// rewrites it into a user-facing explanation and attaches a recoverable
|
||
// cloud-development next step (see appNoDatabaseMessage / appNoDatabaseHint).
|
||
//
|
||
// Two codes, not one: the server renumbered this case from 500002759 to
|
||
// 400002465 when the domain moved its client-class errors into the 4xx band.
|
||
// Keying on a single literal made the recovery flow disappear silently on the
|
||
// day that shipped — no compile error, no failing unit test (they assert against
|
||
// the same constant), and the dry-run E2E never reaches a live server. Hence
|
||
// isAppNoDatabaseError matches code OR message; see that function.
|
||
const (
|
||
appNoDatabaseCode = 400002465 // current
|
||
appNoDatabaseLegacyCode = 500002759 // pre-4xx renumber; kept so older servers still match
|
||
)
|
||
|
||
// appNoDatabaseMessage is the user-facing explanation for appNoDatabaseCode.
|
||
// It deliberately drops internal workspace / db-branch terms.
|
||
const appNoDatabaseMessage = "this app does not have a database yet"
|
||
|
||
// appNoDatabaseHint guides adding a database via Miaoda cloud development. It
|
||
// only uses existing commands with stable placeholder args, so a harness can
|
||
// execute it without matching natural-language error text. Adding a database is
|
||
// a cloud write: a failed read alone does not authorize it — confirm with the
|
||
// user before starting a +chat.
|
||
const appNoDatabaseHint = "ask the user whether to add a database through Miaoda cloud development; if confirmed, run `lark-cli apps +session-list --app-id <app_id>` and reuse an active session, or run `lark-cli apps +session-create --app-id <app_id>`; send the database requirement with `lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message \"<database requirement>\"`, poll `lark-cli apps +session-get --app-id <app_id> --session-id <session_id>` until `latest_turn.status=completed`, then retry the original db command"
|
||
|
||
// appNoContainerCode is the Spark business code returned by the online
|
||
// observability endpoints (query_metrics_data / query_analytics_data) when the
|
||
// target app has no running container/deployment. It is an expected business
|
||
// state, not a system fault: an app that is not deployed and serving traffic
|
||
// produces no client_api_request metrics or analytics to query. The raw upstream
|
||
// message is "Container not exists", which reads like an infrastructure failure
|
||
// and misleads callers (including AI agents) into retrying a non-retryable,
|
||
// expected state — so the observability commands rewrite it into a user-facing
|
||
// explanation with a deploy-then-retry next step (see withObservabilityHint).
|
||
const appNoContainerCode = 400002655
|
||
|
||
// appNoContainerMessage is the user-facing explanation for appNoContainerCode.
|
||
// It replaces the infra-sounding "Container not exists" with the actual cause.
|
||
const appNoContainerMessage = "this app has no running container; online metrics and analytics are only produced after the app is deployed and serving traffic"
|
||
|
||
// appNoContainerHint guides diagnosing and (only with user authorization)
|
||
// deploying the app so observability data starts flowing. Deploying via
|
||
// +release-create is a "write" that takes the whole app live and can affect
|
||
// existing production traffic, so — like appNoDatabaseHint — the hint gates
|
||
// that go-live behind an explicit user confirmation and leads with a read-only
|
||
// status check; a failed metrics read alone does not authorize a release. It
|
||
// names existing commands with a stable placeholder arg so a harness can act on
|
||
// it without parsing natural-language error text.
|
||
const appNoContainerHint = "check the app's deployment status with `lark-cli apps +release-list --app-id <app_id> --status finished` (a newly created or undeployed app has no finished release, so it produces no metrics yet); if it is not deployed, ask the user whether to deploy — deploying takes the whole app live and can affect existing production traffic — and only if confirmed run `lark-cli apps +release-create --app-id <app_id>`, then retry once it is serving traffic"
|
||
|
||
// appNoContainerMessageMarkers are lowercase substrings of the raw "Container
|
||
// not exists" server message, used as a fallback when the business code is not
|
||
// the one the CLI knows. A bare numeric literal is not a stable signal — the
|
||
// no-database case (see isAppNoDatabaseError) was silently dropped when the
|
||
// server renumbered it, with nothing in CI to catch it — so detection is
|
||
// code-OR-message here too. "not exist" (no trailing s) matches both
|
||
// "Container not exists" and a possible "Container not exist".
|
||
var appNoContainerMessageMarkers = []string{
|
||
"container not exist",
|
||
}
|
||
|
||
// isAppNoContainerError reports whether a typed observability failure is "this
|
||
// app has no running container", matching on business code OR raw server
|
||
// message for the same code-vs-message stability trade-off documented on
|
||
// isAppNoDatabaseError. Only consulted from the observability helper
|
||
// (withObservabilityHint), so the message marker is scoped to observability
|
||
// context and cannot hijack unrelated apps failures.
|
||
func isAppNoContainerError(p *errs.Problem) bool {
|
||
if p == nil {
|
||
return false
|
||
}
|
||
if p.Code == appNoContainerCode {
|
||
return true
|
||
}
|
||
message := strings.ToLower(p.Message)
|
||
for _, marker := range appNoContainerMessageMarkers {
|
||
if strings.Contains(message, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// withObservabilityHint wraps an online observability (metric/analytics) API
|
||
// failure. It first rewrites the "app has no running container" business state
|
||
// into a user-facing explanation, since the raw "Container not exists" upstream
|
||
// message reads like an infra fault and drives non-retryable retries; any other
|
||
// failure falls through to the shared app-id recovery hint (which still applies
|
||
// its own no-database override). Scoped to the two observability commands rather
|
||
// than the global withAppsHint chokepoint because 400002655 is not known to be
|
||
// observability-exclusive, and the message-marker fallback must not reach
|
||
// unrelated commands. err==nil and untyped errors pass through unchanged.
|
||
func withObservabilityHint(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
if p, ok := errs.ProblemOf(err); ok && isAppNoContainerError(p) {
|
||
p.Message = appNoContainerMessage
|
||
p.Hint = appNoContainerHint
|
||
return err
|
||
}
|
||
return withAppsHint(err, appIDListHint)
|
||
}
|
||
|
||
// withAppsHint attaches an actionable next-step hint to a typed failure,
|
||
// preserving its original classification (subtype/code/log_id). A hint already
|
||
// present on the error is kept (the upstream wording wins); only an empty hint
|
||
// is filled in. Mirrors drive.appendDriveExportRecoveryHint. err==nil and
|
||
// untyped errors pass through unchanged.
|
||
//
|
||
// Special-case the "app has no database yet" failure (see isAppNoDatabaseError):
|
||
// rewrite the message to a user-facing explanation and force the
|
||
// cloud-development recovery hint, since the raw upstream message uses internal
|
||
// terms and any generic hint would be less actionable. That failure is only
|
||
// produced by db endpoints, so the override is safe to check for every apps
|
||
// command that funnels through here.
|
||
func withAppsHint(err error, hint string) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
// p points at the embedded Problem, so the mutation is reflected in err.
|
||
if p, ok := errs.ProblemOf(err); ok {
|
||
if isAppNoDatabaseError(p) {
|
||
p.Message = appNoDatabaseMessage
|
||
p.Hint = appNoDatabaseHint
|
||
return err
|
||
}
|
||
if strings.TrimSpace(p.Hint) == "" && hintExplainsFailure(p) {
|
||
p.Hint = hint
|
||
}
|
||
return err
|
||
}
|
||
return err
|
||
}
|
||
|
||
// hintExplainsFailure reports whether the caller's hint can plausibly explain this
|
||
// failure. Every hint passed to withAppsHint describes something to fix about the
|
||
// REQUEST — "verify --app-id", "verify table/column names with +db-table-get", "if
|
||
// the release_id is unknown, list releases". Those sentences are only true if the
|
||
// request is what failed.
|
||
//
|
||
// SubtypeFailedPrecondition says the opposite by definition: "the request is valid
|
||
// but the system/resource state is not in the state required to execute; caller
|
||
// must change state (not retry)". Attaching request-shaped advice to it is wrong by
|
||
// construction — that is how 221800 "miaoda UAT not activated", a tenant that never
|
||
// enabled Miaoda, came back advised to "verify table/column names ... target the dev
|
||
// database with --environment dev": advice that cannot help and sends an agent
|
||
// looping over table lookups.
|
||
//
|
||
// Blast radius is two Spark codes, because that is all this subtype covers here:
|
||
// - 221800 — the case above; now withheld.
|
||
// - 400002655 "no running container" — only when it reaches a non-observability
|
||
// command; the observability pair rewrites it first (withObservabilityHint), and
|
||
// "verify --app-id" was never the fix for an undeployed app anyway.
|
||
//
|
||
// The other precondition codes never reach this line: 400002465 / 500002759
|
||
// (no database) are intercepted by the isAppNoDatabaseError branch above, and
|
||
// 400002479 (db-sync task state) is served by withDBSyncHint, which does not
|
||
// delegate here.
|
||
//
|
||
// Deliberately one subtype. Gating on Category instead (deny authentication /
|
||
// authorization / network / ...) contradicts contracts this package asserts on
|
||
// purpose: an authentication failure on +role-list (99991663) is expected to keep
|
||
// the app-access hint, and a 503 on credential issuance the developer-access hint.
|
||
// Those hints are broad enough to survive a caller-standing failure; only the
|
||
// precondition class is guaranteed to be misdescribed. Widen only with a failing
|
||
// case proving the hint is wrong for the new class.
|
||
func hintExplainsFailure(p *errs.Problem) bool {
|
||
return p.Subtype != errs.SubtypeFailedPrecondition
|
||
}
|
||
|
||
// appNoDatabaseMessageMarkers are lowercase substrings of the raw server message
|
||
// for the no-database failure, used as a fallback when the business code is not
|
||
// one the CLI knows. They quote the server's internal vocabulary — "db branch",
|
||
// "workspace id ... app id" — which is exactly why the message gets rewritten for
|
||
// users.
|
||
//
|
||
// Deliberately narrow. A looser marker such as "workspace" alone would swallow
|
||
// neighbouring db failures that need their own hint; "no db branch" in particular
|
||
// must not also match env-pull's "invalid db branch" case
|
||
// (isEnvPullDevDBNotInitializedError). Widen only with a test proving the
|
||
// neighbours still pass through.
|
||
var appNoDatabaseMessageMarkers = []string{
|
||
"get workspace id failed by app id",
|
||
"no db branch",
|
||
}
|
||
|
||
// isAppNoDatabaseError reports whether a typed failure is "this app has no
|
||
// database yet", matching on business code OR raw server message.
|
||
//
|
||
// Why both: the code is the precise signal but not a stable one — the server has
|
||
// already renumbered this case once (500002759 → 400002465), and a code-only
|
||
// check fails open, silently dropping the recovery flow with nothing in CI to
|
||
// catch it. The message is the reverse trade: it survives renumbering but breaks
|
||
// on rewording or localization. Requiring either to match means one channel
|
||
// changing degrades nothing, and only a simultaneous change of both regresses.
|
||
func isAppNoDatabaseError(p *errs.Problem) bool {
|
||
if p == nil {
|
||
return false
|
||
}
|
||
if p.Code == appNoDatabaseCode || p.Code == appNoDatabaseLegacyCode {
|
||
return true
|
||
}
|
||
message := strings.ToLower(p.Message)
|
||
for _, marker := range appNoDatabaseMessageMarkers {
|
||
if strings.Contains(message, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
|
||
// meta_token values are rejected with a hint to resolve via +get first.
|
||
func validateRealAppID(appID string) error {
|
||
if !strings.HasPrefix(appID, "app_") {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
`--app-id must be an app_id starting with "app_".`,
|
||
).WithParam("--app-id").WithHint(
|
||
`If you have a meta_token or a /page/<token>/ link, first resolve it:
|
||
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
|
||
Then retry this command with the returned app_id.`,
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||
// --output path. The authoritative guard is the local FileIO layer
|
||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||
// symlinks), so traversal is already blocked at write time; this gives an
|
||
// earlier, clearer validation error and pins the contract in the command layer.
|
||
// Empty (use server-derived default) passes through. Absolute paths and any
|
||
// ".." path component are rejected.
|
||
func rejectOutputTraversal(output string) error {
|
||
o := strings.TrimSpace(output)
|
||
if o == "" {
|
||
return nil
|
||
}
|
||
if filepath.IsAbs(o) {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--output must be a relative path within the current directory, got %q", o).WithParam("--output")
|
||
}
|
||
for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) {
|
||
if seg == ".." {
|
||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||
"--output must not contain .. path traversal, got %q", o).WithParam("--output")
|
||
}
|
||
}
|
||
return nil
|
||
}
|