fix(0.30.6): sec-20 follow-up -- malformed branch-mapping raises ConfigError (clean envelope) - #273
Conversation
…envelope
v0.30.5's sec-20 fix added a descriptive error message for malformed
.keboola/branch-mapping.json but raised it as a bare ValueError.
CLI commands didn't catch ValueError, so an end user with a
hand-edited mapping file saw a multi-frame Python traceback dumped to
stderr instead of the standard JSON error envelope.
Found during v0.30.5 e2e smoke test against the kbagent-e2e project.
Not a security regression -- the descriptive content was correct --
but a clear UX cleanup.
Fix: load_branch_mapping() now raises ConfigError directly (with the
same descriptive "Failed to parse <path>: Invalid branch ID ..."
message). Existing 'except ConfigError' handlers in commands/sync.py
catch it via the standard path and emit:
{
"status": "error",
"error": {
"code": "CONFIG_ERROR",
"message": "Failed to parse /.../branch-mapping.json: ...",
...
}
}
with exit code 5 and no stack trace.
cleanup_branch_id_from_mapping() extended to catch ConfigError
alongside ValueError so its best-effort skip behavior is preserved.
BranchMapping.from_dict() continues to raise ValueError (it's the
data-parser layer; ConfigError requires filesystem context which only
load_branch_mapping has).
Test:
- test_load_branch_mapping_invalid_id_raises_config_error: asserts
ConfigError with the same descriptive message
- E2E re-run: clean JSON envelope confirmed; exit 5; zero traceback
lines
Total suite: 2834 passed.
padak
left a comment
There was a problem hiding this comment.
Review of #273 — fix(0.30.6): sec-20 follow-up -- malformed branch-mapping raises ConfigError (clean envelope)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
The PR converts ValueError to ConfigError in load_branch_mapping() so that CLI commands with except ConfigError handlers catch it and emit a clean exit-5 JSON envelope instead of a Python traceback. The boundary choice is correct: BranchMapping.from_dict() (data parser) continues to raise ValueError, while load_branch_mapping() (filesystem-aware wrapper) converts it. One gap was found: sync_branch_status in commands/sync.py only catches FileNotFoundError and would still surface a traceback for a corrupted mapping file. The dead-code comment on the ValueError catch in cleanup_branch_id_from_mapping is mildly misleading. All other affected call sites are safe. make check passes (2834 passed, 7 skipped). Verdict: REQUEST CHANGES (one blocking finding).
Verdict
- Verdict: REQUEST CHANGES
- Blocking findings: 1
- Non-blocking findings: 1
- Nits: 1
Blocking findings
[B-1] src/keboola_agent_cli/commands/sync.py:992-996 — sync branch-status does not catch ConfigError, nullifying the fix for that command
sync_branch_status (line 982) wraps its service call in try: ... except FileNotFoundError only. SyncService.branch_status() calls load_branch_mapping() at line 2087 inside a try: ... except FileNotFoundError block too; a ConfigError from a malformed file escapes that inner handler and propagates up to the command, which has no ConfigError catch. The user still sees an unhandled exception traceback when running kbagent sync branch-status against a workspace with a corrupted branch-mapping.json, defeating the purpose of this PR for that command.
Fix: add except ConfigError as exc: formatter.error(...); raise typer.Exit(code=5) from None to sync_branch_status in commands/sync.py, mirroring the pattern used in sync_branch_link (line 926) and sync_branch_unlink (line 967). All other call sites (branch_link, branch_unlink, sync_pull, sync_push, _resolve_branch_id) already have upstream except ConfigError coverage.
Non-blocking findings
[NB-1] src/keboola_agent_cli/sync/branch_mapping.py:159 — ValueError catch in cleanup_branch_id_from_mapping is now dead code with a misleading justification comment
The comment says "ValueError preserved for backward compat with callers that may still trigger it via custom paths." After this PR, load_branch_mapping() wraps every ValueError as ConfigError before returning, so no ValueError can reach line 159 from load_branch_mapping(). save_branch_mapping() and find_sync_workspace() (the other functions callable from the same scope) do not raise ValueError. The dead catch is harmless at runtime, but the comment implies a real code path exists when it does not, which will mislead the next contributor.
Fix: either drop the ValueError from the tuple (making it except (FileNotFoundError, ConfigError)) and update the comment, or change the comment to accurately say "ValueError is a defensive catch; no current code path in load_branch_mapping() produces it after 0.30.6."
Nits
[NIT-1]src/keboola_agent_cli/changelog.py:47-48— The two changelog entries for 0.30.6 are extremely long (828 and 440 characters). The repo's existing style (see the 0.30.5 entries) is also verbose, so this is consistent, but the 0.30.6 entries read more like commit messages than user-facing release notes. Consider trimming the second entry ("Tests: ...") to a single short sentence -- or dropping it entirely since test changes are not user-facing.
Verification log
gh pr view 273 --json title,body,files,additions,deletions,state-> 7 files, +30/-13, state OPEN, conventionalfix(0.30.6):prefix matches bug-fix nature of change. OK.git rev-parse --abbrev-ref HEAD->fix/sec-20-clean-error-envelope(matches PR branch). OK.grep -E 'from typer|import typer|formatter\.|console\.print' /tmp/kbagent-pr-273.diff-> empty. No layer violation.grep -E 'from httpx|import httpx' /tmp/kbagent-pr-273.diff-> empty. No HTTP in commands.grep -E 'except\s*:' /tmp/kbagent-pr-273.diff-> empty. No bare except.grep -E 'print\(' /tmp/kbagent-pr-273.diff | grep src/-> empty. No raw print in production code.grep -E '(token|TOKEN)' /tmp/kbagent-pr-273.diff | grep -v mask_token-> empty. No token exposure.- Layer boundary check:
BranchMapping.from_dict()still raisesValueError(data-parser, correct).load_branch_mapping()converts toConfigError(filesystem boundary, correct)._coerce_keboola_id()still raisesValueError(internal helper, correct). cleanup_branch_id_from_mapping()caller check: catches(FileNotFoundError, ConfigError, ValueError).ConfigErrorcatches the new exception shape. DeadValueErrorconfirmed (noload_branch_mappingcode path emits it post-fix).- Call-site audit for all
load_branch_mapping()callers insync_service.py:- Line 1958 (
branch_link):except FileNotFoundError+ CLIexcept ConfigErrorat command line 926. ConfigError propagates safely. OK. - Line 2053 (
branch_unlink): no local try/except; CLIexcept ConfigErrorat command line 967 catches it. OK. - Line 2087 (
branch_status):except FileNotFoundErroronly. CLIsync_branch_statusat line 994 catches onlyFileNotFoundError. ConfigError escapes uncaught. GAP -> [B-1]. - Line 2146 (
_resolve_branch_id):except FileNotFoundErroronly; callers arepull/push/pull_all/push_allwhich all propagate to CLI commands that do catchConfigError(lines 522, 848). OK.
- Line 1958 (
make check->2834 passed, 7 skipped, 73 deselected, 14 warnings(exit 0). OK.ConfigErrorinheritance:class ConfigError(Exception)-- NOT a subclass ofValueError. Existing code catchingValueErrorfromload_branch_mappingwould miss the new exception. No such external callers found insrc/(onlycleanup_branch_id_from_mappingexplicitly catches both). OK.- Plugin sync surfaces: no new command added; only a version bump.
pyproject.toml,plugin.json,marketplace.json,changelog.pyall updated. Nokeboola-expert.md,commands-reference.md, orgotchas.mdupdate required (error-envelope shape is an internal UX fix, not an AI-agent-observable behavior change). - Behavior reproduction: could not reproduce against live environment (no E2E credentials in this session). PR describes E2E log output confirming exit-5 + zero traceback lines; claimed smoke-test result accepted without live re-run.
Open questions for the author
- Is
sync branch-statuswith a corrupted mapping file intentionally left as best-effort (return "not linked") rather than error? If so, the fix belongs in the service layer (branch_statusshould catchConfigErrorfromload_branch_mappingand treat it likeFileNotFoundError). If the intent is to surface the error to the user, the fix belongs in the command layer. Either approach resolves [B-1]; the choice depends on whether a corrupted mapping during a read-only status query should silently degrade or loudly fail.
Reviewer found that sync_branch_status (commands/sync.py:992) catches only FileNotFoundError, not ConfigError. After PR #273's load_branch_mapping ValueError -> ConfigError conversion, a corrupted .keboola/branch-mapping.json still produced a Python traceback when the user ran 'kbagent sync branch-status' (the diagnostic command they would naturally reach for to debug a corrupted workspace). All other sync commands (sync_pull, sync_push, sync_diff, sync_branch_link, sync_branch_unlink) already had the 'except ConfigError' handler -- only sync_branch_status was missing. Added it now with the same exit-5 + CONFIG_ERROR envelope shape. Test: new TestSyncBranchStatusCli :: test_sync_branch_status_corrupted_mapping_clean_envelope mocks the service raising ConfigError and asserts exit 5 + clean JSON envelope + no 'Traceback' string in output. E2E re-verified against /tmp/kbagent-e2e: corrupted mapping -> clean error envelope with descriptive message, exit 5, zero traceback lines.
|
Thanks for catching this. Pushed What was missingYou're right: All other sync commands ( Fix
try:
result = service.branch_status(project_root=project_root)
except FileNotFoundError as exc:
formatter.error(message=str(exc), error_code=ErrorCode.NOT_INITIALIZED)
raise typer.Exit(code=1) from None
except ConfigError as exc:
# Corrupted .keboola/branch-mapping.json -- surface as clean
# exit-5 envelope so the user sees the descriptive message
# ("Failed to parse ...: Invalid branch ID ...") instead of a
# Python traceback. Mirrors the handler other sync commands
# already have (issue #269 sec-20 follow-up).
formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
raise typer.Exit(code=5) from NoneTestAdded
E2E re-verified against NB-1 / NIT-1Both already validated as not affecting safety:
Total suite: 2835 passed (was 2834; +1 new test). Lint + format clean. Re-review when convenient. |
padak
left a comment
There was a problem hiding this comment.
Review of #273 — fix(0.30.6): sec-20 follow-up -- malformed branch-mapping raises ConfigError (clean envelope)
Generated by
kbagent-pr-reviewersubagent (re-review after commit ca94598). Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR fixes the UX gap left by v0.30.5: load_branch_mapping() now raises ConfigError instead of bare ValueError when branch-mapping.json is malformed, so all existing except ConfigError handlers in commands/sync.py produce a clean exit-5 JSON envelope instead of a Python traceback. The previously blocking finding (B-1 from the first review pass) has been addressed in commit ca94598, which adds the missing except ConfigError clause to sync_branch_status and a regression test that exercises the new code path end-to-end via CliRunner. The diff is small, targeted, and mechanically correct.
Verdict: APPROVE. Zero blocking findings. One non-blocking observation about a comment that is now technically misleading (the ValueError arm in cleanup_branch_id_from_mapping's except tuple is unreachable via load_branch_mapping itself, but is harmless). One NIT on changelog verbosity.
Verdict
- Verdict: APPROVE
- Blocking findings: 0
- Non-blocking findings: 1
- Nits: 1
Blocking findings
(none)
Non-blocking findings
[NB-1] src/keboola_agent_cli/sync/branch_mapping.py:159 — ValueError arm in cleanup_branch_id_from_mapping except-tuple is now unreachable via load_branch_mapping
The comment on line 160-162 justifies keeping ValueError in except (FileNotFoundError, ConfigError, ValueError) as "backward compat with callers that may still trigger it via custom paths." However, load_branch_mapping is the only function called inside that try block, and after this PR it converts every ValueError from _coerce_keboola_id / json.loads into ConfigError before it exits. No other call-site invokes load_branch_mapping with a path that could produce a raw ValueError. The ValueError arm is therefore dead code in all current production paths.
This is harmless -- an extra arm in an except tuple has zero runtime cost and no behavior change -- but the accompanying comment is now misleading because it implies an active code path. If a future refactor ever removes the ConfigError conversion in load_branch_mapping, a reviewer might trust the comment and assume ValueError is still covered, leading to confusion.
Fix (optional): either drop ValueError from the tuple (and the comment) or strengthen the comment: "ValueError is dead code here since load_branch_mapping no longer propagates it, but kept so that any future direct call to BranchMapping.from_dict (the data-parser layer, which does still raise ValueError) would also be silently swallowed."
Nits
[NIT-1]src/keboola_agent_cli/changelog.py:12-13— The two changelog strings for 0.30.6 are unusually long (the second entry is ~370 chars). Thechangelog.pydocstring says "brief one-line descriptions." Both entries read more like a commit message than a user-facing changelog bullet. Consider collapsing into one concise entry like:"UX (sec-20): malformed .keboola/branch-mapping.json now shows a clean JSON error (exit 5, CONFIG_ERROR) instead of a Python traceback. sync_branch_status added the missing ConfigError handler; load_branch_mapping() raises ConfigError instead of bare ValueError."Cosmetic only.
Verification log
gh auth status→ authenticated aspadakongithub.laiyagushi.com✓git rev-parse --abbrev-ref HEAD→fix/sec-20-clean-error-envelope(matches<branch>) ✓gh pr view 273 --json state→"OPEN"✓;additions: 79, deletions: 13, 9 files touched ✓gh pr diff 273→ 225-line diff captured to/tmp/kbagent-pr-273.diff✓- Read
CONTRIBUTING.mdPlugin synchronization map: this PR introduces no new CLI commands (no new@*_app.command()decorators), bumps onlypyproject.toml/plugin.json/marketplace.jsonversion, and updates changelog and two test files. No plugin-sync-map rows are applicable except the release-time ones (version bump + changelog). Both are updated:pyproject.toml→ 0.30.6,changelog.pyhas entry,plugin.jsonandmarketplace.jsonauto-synced. ✓ grep -E '^\+(from typer|import typer|...)' diff | grep services/→ empty ✓ (no layer violations)grep -E '^\+(from httpx|import httpx|...)' diff | grep commands/→ empty ✓- B-1 closed:
commands/sync.py:997-1004now hasexcept ConfigError as exc: formatter.error(...); raise typer.Exit(code=5)immediately after theFileNotFoundErrorhandler insync_branch_status. Verified: all other sync commands (sync_pullline 522,sync_pushline 848,sync_branch_linkline 926,sync_branch_unlinkline 967) already had equivalent handlers pre-patch. ✓ - New test exercises the code path:
test_sync_branch_status_corrupted_mapping_clean_envelopesetsmock_sync.branch_status.side_effect = ConfigError(...), invokes["--json", "sync", "branch-status", ...]viaCliRunner, and assertsexit_code == 5,"Traceback" not in output,data["error"]["code"] == "CONFIG_ERROR","Invalid branch ID" in data["error"]["message"]. The mock injectsConfigErrordirectly at the service boundary, which is the correct layer to test the CLI handler in isolation. ✓ - No regression in other sync_branch_ commands*:
branch_link,branch_unlink, andbranch_statusservice methods callload_branch_mappingcatching onlyFileNotFoundError;ConfigErrorpropagates to the command layer where all three have matching handlers._resolve_branch_id(used bysync_pull/sync_push) also only catchesFileNotFoundErrorfromload_branch_mapping;ConfigErrorpropagates to the pull/push handlers which already have it. No handler gap found. ✓ BranchMapping.from_dictstill raisesValueError: confirmed atbranch_mapping.py:36. No external production code callsfrom_dictdirectly (onlyload_branch_mappingdoes, which wraps it). Testtest_branch_mapping_from_dict_invalid_id_descriptive_error(line 176) still assertsValueErrorfrom the data-parser layer. ✓- Convention checks: no magic numbers, no raw
error_code=string literals, no bareexcept:, noprint()in prod code, no token patterns in new log output. ✓ (all grep checks empty) make check→ 2835 passed, 7 skipped, 73 deselected, 15 warnings — exit 0 ✓- Behavior reproduction: Could not re-run the full e2e smoke (no
E2E_API_TOKENavailable in this session). The author's E2E log excerpt in the PR description showsEXIT=5,"code": "CONFIG_ERROR",Traceback lines: 0. The unit test provides adequate coverage for the specific new handler; marking as confirmed-by-test rather than confirmed-by-e2e.
Open questions for the author
(none)
Adds `--new-alias NEW` to `kbagent project edit` so users can rename a project alias without going through `project remove` + `project add` (which forces token re-entry). Adds `--dry-run` for read-only preview. Mirrors the `kbagent config rename` precedent for the on-disk part of the cascade. Cascading scope: - config.json `projects` dict key (`pop(old)` + insert under `new`) - config.json `default_project` field (when it matched the old alias) - nested-layout sync directory `<cwd>/<old-alias>/` -> `<cwd>/<new-alias>/` (with -2 collision suffix, git-mv-with-shutil-fallback) - WARNS on `*.lineage.json` -- caches embed alias FQNs and are NOT auto-rewritten (partial rewrites are worse than no rewrite) `--dry-run` (PR keboola#266 review NIT, addressed) previews collision detection, planned disk-rename method (`git_mv` vs `shutil_move`), and the lineage- cache warning without mutating any state. Validation errors raise the same `ConfigError` exit-5 codes as the live path -- callers can rely on `--dry-run` as a 1:1 pre-flight. Combined with `--url` and/or `--token` in one call, those mutations target the new alias post-rename: `kbagent project edit --project foo --new-alias bar --token NEW` is one atomic operation. Service / Command: - `commands/project.py` -- new `--new-alias` and `--dry-run` Typer options; human formatter branches on `dry_run` / `old_alias` - `services/project_service.py` -- `edit_project` accepts `new_alias`, `search_root`, `dry_run`; new helpers `_rename_project_alias`, `_validate_alias_format`, `_rename_nested_sync_dir`, `_move_directory`, `_detect_lineage_cache_warning` for the live path; `_plan_project_alias_rename` and `_plan_nested_sync_dir` for the read-only dry-run path ConfigStore: - `config_store.py` -- new `rename_project(old, new)` method (atomic dict-key swap + `default_project` cascade in one save() call) Security hardening (from review iter 2): - Validator regex `[A-Za-z0-9_][A-Za-z0-9_.-]*` plus explicit `..` rejection; rejects path traversal, NUL bytes, leading dot/dash, whitespace, and characters outside the slug alphabet. Stricter than `project add`'s no-op check; rationale is the rename's filesystem interaction (alias becomes a directory name). - `search_root` resolved via `Path.resolve()` once before the disk rename to collapse symlinks; closes a malicious-cwd vector. - Disk rename failures (`OSError`) trigger a config rollback so config and disk never end up out of sync; rollback's own failure is suppressed via `contextlib.suppress` so the original error wins. - Lineage cache scan depth-capped at 2 levels (top + `*/` + `*/*/`) to bound cost when search_root is a deep tree. Tests: 38 new (32 service + 6 CLI). Pin alias-key swap, collision rejection, default_project cascade, sync-dir disk rename, no-sync-dir no-op, sync-dir collision -2 suffix, combined edit-and-rename, no-op on same-alias-only, parametrized 9-input path-traversal validator, legal slug shapes accepted, OS failure rolls config back, rollback failure surfaces original error, symlink target collision triggers suffix bump, dry-run no-mutation happy path, dry-run collision still raises, dry-run format validation still raises, dry-run predicts disk method without touching disk, dry-run human DRY-RUN label, dry-run JSON planned-block shape. E2E: `tests/test_e2e.py::_test_project_edit_and_remove` extended with a `--dry-run` preview (planned-block assertion) followed by a live `--new-alias` round-trip (rename + reverse-rename to baseline) before the existing `--url` step. Pinned by Padak's PR keboola#266 review BLOCKING -- every CLI command must have E2E coverage per CONTRIBUTING.md / convention keboola#16. Sync map updates: - AGENT_CONTEXT (commands/context.py) -- new flags mentioned - CLAUDE.md `## All CLI Commands` -- same wording - commands-reference.md -- expanded with cascade scope + dry-run - gotchas.md -- new `(since v0.30.7)` entry on lineage cache rebuild - keboola-expert.md -- VERSION GATE clause + tool selection matrix row Live-validated against project 1143 (`99_Playground_Max`, europe-west3.gcp.keboola.com): rename to `playground` + reverse rename to baseline; nested sync dir moved on disk; default_project cascaded; all error paths produce correct ConfigError exit-5 messages. Three review iterations on the original PR: self -> independent -> convergence; zero material findings on the convergence pass. Padak's post-merge review found 1 BLOCKING (E2E coverage) + 1 NIT (`--dry-run`); both addressed in this iteration. Rebased onto upstream/main after Padak shipped 0.30.4 (keboola#268), 0.30.5 (keboola#272), and 0.30.6 (keboola#273) since the original PR opened. This PR ships as 0.30.7.
…un (#266) Adds `--new-alias NEW` to `kbagent project edit` so users can rename a project alias without going through `project remove` + `project add` (which forces token re-entry). Adds `--dry-run` for read-only preview. Mirrors the `kbagent config rename` precedent for the on-disk part of the cascade. Cascading scope: - config.json `projects` dict key (`pop(old)` + insert under `new`) - config.json `default_project` field (when it matched the old alias) - nested-layout sync directory `<cwd>/<old-alias>/` -> `<cwd>/<new-alias>/` (with -2 collision suffix, git-mv-with-shutil-fallback) - WARNS on `*.lineage.json` -- caches embed alias FQNs and are NOT auto-rewritten (partial rewrites are worse than no rewrite) `--dry-run` (PR #266 review NIT, addressed) previews collision detection, planned disk-rename method (`git_mv` vs `shutil_move`), and the lineage- cache warning without mutating any state. Validation errors raise the same `ConfigError` exit-5 codes as the live path -- callers can rely on `--dry-run` as a 1:1 pre-flight. Combined with `--url` and/or `--token` in one call, those mutations target the new alias post-rename: `kbagent project edit --project foo --new-alias bar --token NEW` is one atomic operation. Service / Command: - `commands/project.py` -- new `--new-alias` and `--dry-run` Typer options; human formatter branches on `dry_run` / `old_alias` - `services/project_service.py` -- `edit_project` accepts `new_alias`, `search_root`, `dry_run`; new helpers `_rename_project_alias`, `_validate_alias_format`, `_rename_nested_sync_dir`, `_move_directory`, `_detect_lineage_cache_warning` for the live path; `_plan_project_alias_rename` and `_plan_nested_sync_dir` for the read-only dry-run path ConfigStore: - `config_store.py` -- new `rename_project(old, new)` method (atomic dict-key swap + `default_project` cascade in one save() call) Security hardening (from review iter 2): - Validator regex `[A-Za-z0-9_][A-Za-z0-9_.-]*` plus explicit `..` rejection; rejects path traversal, NUL bytes, leading dot/dash, whitespace, and characters outside the slug alphabet. Stricter than `project add`'s no-op check; rationale is the rename's filesystem interaction (alias becomes a directory name). - `search_root` resolved via `Path.resolve()` once before the disk rename to collapse symlinks; closes a malicious-cwd vector. - Disk rename failures (`OSError`) trigger a config rollback so config and disk never end up out of sync; rollback's own failure is suppressed via `contextlib.suppress` so the original error wins. - Lineage cache scan depth-capped at 2 levels (top + `*/` + `*/*/`) to bound cost when search_root is a deep tree. Tests: 38 new (32 service + 6 CLI). Pin alias-key swap, collision rejection, default_project cascade, sync-dir disk rename, no-sync-dir no-op, sync-dir collision -2 suffix, combined edit-and-rename, no-op on same-alias-only, parametrized 9-input path-traversal validator, legal slug shapes accepted, OS failure rolls config back, rollback failure surfaces original error, symlink target collision triggers suffix bump, dry-run no-mutation happy path, dry-run collision still raises, dry-run format validation still raises, dry-run predicts disk method without touching disk, dry-run human DRY-RUN label, dry-run JSON planned-block shape. E2E: `tests/test_e2e.py::_test_project_edit_and_remove` extended with a `--dry-run` preview (planned-block assertion) followed by a live `--new-alias` round-trip (rename + reverse-rename to baseline) before the existing `--url` step. Pinned by Padak's PR #266 review BLOCKING -- every CLI command must have E2E coverage per CONTRIBUTING.md / convention #16. Sync map updates: - AGENT_CONTEXT (commands/context.py) -- new flags mentioned - CLAUDE.md `## All CLI Commands` -- same wording - commands-reference.md -- expanded with cascade scope + dry-run - gotchas.md -- new `(since v0.30.7)` entry on lineage cache rebuild - keboola-expert.md -- VERSION GATE clause + tool selection matrix row Live-validated against project 1143 (`99_Playground_Max`, europe-west3.gcp.keboola.com): rename to `playground` + reverse rename to baseline; nested sync dir moved on disk; default_project cascaded; all error paths produce correct ConfigError exit-5 messages. Three review iterations on the original PR: self -> independent -> convergence; zero material findings on the convergence pass. Padak's post-merge review found 1 BLOCKING (E2E coverage) + 1 NIT (`--dry-run`); both addressed in this iteration. Rebased onto upstream/main after Padak shipped 0.30.4 (#268), 0.30.5 (#272), and 0.30.6 (#273) since the original PR opened. This PR ships as 0.30.7.
Quick UX cleanup follow-up to v0.30.5 / PR #272.
What v0.30.5 shipped (sec-20)
load_branch_mapping()raises a descriptive error with file path + bad value whenbranch-mapping.jsoncontains a malformed branch ID:That message is correct. The error was raised as a bare
ValueError.What v0.30.5 missed
CLI commands wrap their service calls in
except ConfigError(mapped to exit 5 + clean JSON envelope) andexcept KeboolaApiError. PlainValueErroris not caught — so on a malformed mapping file, the user actually sees:Found during the v0.30.5 e2e smoke test against the
kbagent-e2eproject. Not a security regression — the message is correct — but the multi-frame traceback masks the actual error and exits 1 (general error) instead of 5 (config error).Fix
load_branch_mapping()now raisesConfigErrordirectly with the same descriptive message. Existing CLIexcept ConfigErrorhandlers catch it and emit the standard envelope:{ "status": "error", "error": { "code": "CONFIG_ERROR", "error_type": "configuration", "message": "Failed to parse /.../branch-mapping.json: Invalid branch ID in branch-mapping.json: 'not-a-number'. Expected null or an integer; got str.", "project": "", "retryable": false } }Exit 5. Zero traceback lines.
Scope
BranchMapping.from_dict()— unchanged. Still raisesValueError. It's the data-parser layer; raising the generic Python exception is correct there.load_branch_mapping()— changed. Wraps the innerValueErrorand re-raises asConfigError. This is the filesystem-aware boundary, so it has the path context to make a "ConfigError"-class error meaningful.cleanup_branch_id_from_mapping()— extended to catchConfigErroralongside the legacyValueErrorso its best-effort skip behavior on a corrupted workspace mapping is preserved.Test
Updated regression test from v0.30.5:
E2E verification (preserved in
/tmp/kbagent-e2e-logs/sec20-cleanenv-*.log):Test plan
make lint format-check skill-check changelog-check check-error-codespytest tests/ --ignore=...→ 2834 passedWhy a separate PR vs. amending #272
Related