Skip to content

fix(extensions): refuse web UI install on local edits (swamp-club#129)#1191

Merged
stack72 merged 1 commit intomainfrom
worktree-ethereal-riding-cascade
Apr 20, 2026
Merged

fix(extensions): refuse web UI install on local edits (swamp-club#129)#1191
stack72 merged 1 commit intomainfrom
worktree-ethereal-riding-cascade

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 20, 2026

Summary

Third in the data-loss family (sibling of #121/#1187 and #126/#1190). swamp open's web UI install callback calls pullExtension with force: true and, pre-fix, had no local-edits check — so hitting POST /api/extensions/install for an already-installed extension would silently overwrite anything the user had edited under .swamp/pulled-extensions/<name>/.

Port #126's filesChecksum anchor + detectLocalEdits pattern into a shared libswamp helper and consult it from the web UI before the force-pull. On mismatch, throw a typed LocalEditsError; the HTTP handler maps it to 409 Conflict with a body that names the extension and the opt-in remediation (swamp extension pull <name> --force).

The ui.ts Install button only renders for uninstalled extensions today, so there's no UI click path that reaches this bug — the fix is defense-in-depth for the HTTP endpoint itself, which is reachable by curl, scripts, or any future UI change (e.g. an "Update / Reinstall" button). Scope and fix-shape intentionally mirror #126.

What changes

  • New src/libswamp/extensions/local_edits.ts — shared helper (detectLocalEditsForExtension, LocalEditsStatus, LocalEditsError), re-exported through libswamp/mod.ts
  • src/cli/resolve_datastore.tsIssue 11: Remove deprecated artifact entities and repositories #126's inline detectLocalEdits closure collapses to a one-line call into the new helper (pure DRY)
  • src/cli/commands/open.tsinstallExtension callback runs the local-edits check before force-pull; throws LocalEditsError on mismatch
  • src/serve/open/http.tshandleExtensionInstall maps LocalEditsError → 409, unchanged 500 fallthrough for other errors
  • Unit tests at the new helper and at the HTTP handler layer

What's out of scope

  • extension_update.ts:118 and extension_install.ts:114 still use force:true; the former is an explicit user command, the latter is lockfile-restore. Refusing them would break their contracts.
  • The dependency-with-corrupt-lockfile edge case (parent reinstall recurses into a dep missing from upstream_extensions.json but present on disk with edits) — matches Issue 11: Remove deprecated artifact entities and repositories #126's scope. Fixing it would require pushing the check into installExtension itself, which would wrongly refuse the opt-in paths above.
  • End-to-end UAT for the web UI refuse flow: filed as systeminit/swamp-uat#150.

Test Plan

  • deno check, deno lint, deno fmt, full deno run test (4455 passed), deno run compile
  • New unit coverage: 6 tests in src/libswamp/extensions/local_edits_test.ts (match, mismatch, no-anchor × 3 conditions, LocalEditsError message shape)
  • New handler coverage: 2 tests in src/serve/open/http_test.ts (409 on LocalEditsError, 500 on plain Error as a regression fence)
  • Manual E2E with locally compiled binary: scratch repo → swamp extension pull @stack72/system-extensions → edit a file → curl POST /api/extensions/install → got 409 with the expected remediation body. swamp extension pull --force → curl again → 200.
  • Issue 11: Remove deprecated artifact entities and repositories #126's datastore_auto_update_test.ts and resolve_datastore_test.ts both still green after the helper refactor

🤖 Generated with Claude Code

Port the #126 filesChecksum anchor + local-edits-check pattern into a shared
libswamp helper (`src/libswamp/extensions/local_edits.ts`, exporting
`detectLocalEditsForExtension`, `LocalEditsStatus`, and `LocalEditsError`) and
consult it from the `swamp open` web UI install callback before the force-pull.
When the on-disk per-extension subtree diverges from the stored anchor, the
callback throws `LocalEditsError` instead of silently overwriting; the HTTP
handler at `handleExtensionInstall` maps it to 409 Conflict with a body
directing the user to run `swamp extension pull <name> --force` from the
terminal.

Third in the data-loss family (sibling of #121 and #126). Auto-update and the
web UI are the two implicit force-overwrite surfaces; explicit
`swamp extension pull --force`, lockfile-restore (`extension_install.ts`), and
`swamp extension update` remain opt-in force paths and are not touched.

`resolve_datastore.ts`'s inline detectLocalEdits closure from #126 collapses to
a one-line call to the new shared helper; behavior is identical and the #126
auto-update tests stay green as a regression fence.

Filed as follow-up: systeminit/swamp-uat#150 (end-to-end UAT for the web UI
refuse flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

None.

Suggestions

None.

Verdict

PASS — No user-facing CLI surface changes. The only UX impact is the new LocalEditsError message, which is clear, names the affected extension, and includes the exact remediation command (swamp extension pull <name> --force). The HTTP 409 response body uses the same { error: { message } } shape as every other error response in http.ts. LocalEditsError extends UserError so CLI layers render it without a stack trace.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Blocking Issues

None.

Suggestions

  1. Comment length in open.ts:174-182: The 8-line comment explaining the defense-in-depth rationale is justified (the WHY is non-obvious since there's no current UI click path), but could be trimmed to ~3 lines while preserving the key information (why force:true exists, why only top-level is checked, issue reference).

  2. Pre-existing import boundary violation in resolve_datastore.ts:44: maybeAutoUpdateDatastoreExtension is imported directly from ../libswamp/extensions/datastore_auto_update.ts rather than through ../libswamp/mod.ts. Not introduced by this PR (and this PR actually fixed the equivalent violation for detectLocalEdits), but worth noting as a follow-up cleanup.

What looks good

  • DDD layering: detectLocalEditsForExtension is correctly placed as an application-service function in libswamp, orchestrating domain concepts (extension identity) with infrastructure readers (lockfile, digest). LocalEditsError properly extends UserError as a typed domain error, and the HTTP handler acts as a clean anti-corruption layer translating it to 409.
  • Import boundaries: All CLI/presentation imports (open.ts, http.ts, http_test.ts) go through libswamp/mod.ts. Internal libswamp imports (datastore_auto_update.ts → local_edits.ts) correctly use direct paths.
  • Test coverage: 6 unit tests cover all three LocalEditsStatus outcomes plus edge cases (missing lockfile, missing extension dir) and the error shape. 2 HTTP handler tests verify 409 vs 500 routing.
  • Error degradation: The catch → "no-anchor" pattern ensures a safety-check bug never wedges a user command — correct defensive design.
  • License headers: Both new files include the AGPLv3 header.
  • Type safety: No any types, proper readonly on extensionName, type import for LocalEditsStatus.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

None.

Medium

None.

Low

  1. TOCTOU gap between check and pullsrc/cli/commands/open.ts:182-190: detectLocalEditsForExtension runs, then pullExtension runs with force: true. A file modified in the gap between those two awaits would be silently overwritten. The window is sub-millisecond in practice and this endpoint isn't under concurrent use by the same user, so it's not exploitable — but it's worth noting that the protection is best-effort, not atomic. No fix needed; just a property of the check-then-act pattern.

  2. Fail-open design on infrastructure errorssrc/libswamp/extensions/local_edits.ts:62-64: The catch-all catch { return "no-anchor"; } means a corrupt lockfile (valid JSON structure but bad data types) or a permission error on the extension directory would degrade to "no-anchor" and allow the force-pull. This is documented as intentional ("callers never wedge a user command on a safety-check bug"), and it's the right tradeoff for defense-in-depth — just noting that it's a deliberate fail-open, not fail-closed.

Verdict

PASS — Clean, well-structured PR. The refactoring from inline closure to shared helper is behavior-preserving (verified line-by-line). The new local-edits guard in the web UI install path is correct: it checks before the force-pull, throws a typed LocalEditsError, and the HTTP handler maps it to 409 with actionable remediation. Type export changes in mod.ts are non-breaking. Test coverage hits all three states (match, mismatch, no-anchor) plus the error-class shape and the HTTP 409/500 split. No logic errors, no resource leaks, no security regressions.

@stack72 stack72 merged commit 7649e31 into main Apr 20, 2026
10 checks passed
@stack72 stack72 deleted the worktree-ethereal-riding-cascade branch April 20, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant