Conversation
… ctx.extensionFile() helper (swamp-club#146) Push was flattening additionalFiles to basenames via `basename(af)`, losing any declared directory structure and silently clobbering entries with colliding basenames. Pull's recursive copyDir preserves whatever's in the archive, so the asymmetry broke at push — violating design/extension.md's "preserving their relative paths" contract. Fix at three layers: - Push preserves manifest.additionalFiles entries verbatim under files/, zipping with absolute paths and creating parent dirs as needed. - Resolve-extension-files rejects duplicate entries (NFC-normalized, case-folded) and symlinks with typed UserErrors naming the offenders. - MethodContext and MethodReportContext gain an extensionFile(relPath) helper that resolves asset paths portably across source-loaded and pulled extensions. Mode-aware errors distinguish "re-publish the pulled archive" from "check the source file exists". extensionFilesRoot is populated on ModelDefinition at load time by walking up from source_path to the manifest — no catalog schema migration needed. Direct-content source layouts have no manifest and therefore no additionalFiles concept; the helper correctly returns "not shipped as an extension" for those. swamp-uat coverage gap filed at systeminit/swamp-uat#157. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…afting-kite # Conflicts: # .claude/skills/swamp-extension-publish/references/publishing.md
There was a problem hiding this comment.
CLI UX Review
Blocking
-
extensionFiletyped as optional but documented and always-set as non-optional —src/domain/models/model.ts:357andsrc/domain/reports/report_context.ts:59both declareextensionFile?: (relPath: string) => string(optional). ButbuildMethodContextandbuildMethodReportContextalways populate it, and the SKILL.md examples call it without optional chaining:const promptPath = ctx.extensionFile("prompts/review.md");
In TypeScript strict mode (enforced by this project), calling a
?: (relPath: string) => stringfield directly is a type error — "Cannot invoke an object which is possibly 'undefined'." Extension authors following the SKILL.md pattern will get a confusing TS error and have to reach forctx.extensionFile!("...")orctx.extensionFile?.("..."). Since the function is always present and throws a clearUserErrorwhen called on a non-extension model, the?:buys nothing. Change both declarations to non-optional (extensionFile: (relPath: string) => string) so the documented pattern typechecks cleanly.
Suggestions
-
Inconsistent error message capitalization — New errors in
src/domain/extensions/extension_file_resolver.tsstart lowercase ("extension file not found","ctx.extensionFile() is only available") while every existing error insrc/cli/resolve_extension_files.tsstarts with a capital ("Manifest file not found","Model file not found","Vault file not found", etc.). Capitalize the new messages to match. -
Internal issue number in a user-facing error — The pulled-mode missing-file error in
extension_file_resolver.ts:569reads"(swamp issue-146)". External users filing bug reports or reading docs won't know where to locate that issue. Drop the number or replace with"re-publish the extension and re-pull"alone — the actionable instruction is already there. -
Duplicate-entry advice slightly misleading for path-normalization collisions —
src/cli/resolve_extension_files.ts:444says"Remove one or rename the file so basenames differ". For pure path-normalization duplicates ("./prompts/review.md"vs"prompts/review.md"), the basenames are already identical and the real fix is simply to remove the duplicate entry. Suggest"Remove one entry from the manifest, or rename the file"to cover both cases.
Verdict
NEEDS CHANGES — the extensionFile?: ... optional type will cause TypeScript errors for extension authors following the SKILL.md examples; the field should be non-optional since it is always populated.
There was a problem hiding this comment.
CLI UX Review
Blocking
-
extensionFiletyped as optional but documented and always-set as non-optional —src/domain/models/model.ts:357andsrc/domain/reports/report_context.ts:59both declareextensionFile?: (relPath: string) => string(optional). ButbuildMethodContextandbuildMethodReportContextalways populate it, and the SKILL.md examples call it without optional chaining:const promptPath = ctx.extensionFile("prompts/review.md");
In TypeScript strict mode (enforced by this project), calling a
?: (relPath: string) => stringfield directly is a type error — "Cannot invoke an object which is possibly 'undefined'." Extension authors following the SKILL.md pattern will get a confusing TS error and have to reach forctx.extensionFile!("...")orctx.extensionFile?.("..."). Since the function is always present and throws a clearUserErrorwhen called on a non-extension model, the?:buys nothing. Change both declarations to non-optional (extensionFile: (relPath: string) => string) so the documented pattern typechecks cleanly.
Suggestions
-
Inconsistent error message capitalization — New errors in
src/domain/extensions/extension_file_resolver.tsstart lowercase ("extension file not found","ctx.extensionFile() is only available") while every existing error insrc/cli/resolve_extension_files.tsstarts with a capital ("Manifest file not found","Model file not found","Vault file not found", etc.). Capitalize the new messages to match. -
Internal issue number in a user-facing error — The pulled-mode missing-file error in
extension_file_resolver.tsreads"(swamp issue-146)". External users filing bug reports or reading docs won't know where to locate that issue. Drop the number or replace with"re-publish the extension and re-pull"alone — the actionable instruction is already there. -
Duplicate-entry advice slightly misleading for path-normalization collisions — The new duplicate error in
src/cli/resolve_extension_files.tssays"Remove one or rename the file so basenames differ". For pure path-normalization duplicates ("./prompts/review.md"vs"prompts/review.md"), the basenames are already identical and the real fix is simply to remove the duplicate entry. Suggest"Remove one entry from the manifest, or rename the file"to cover both cases.
Verdict
NEEDS CHANGES — the extensionFile?: ... optional type will cause TypeScript errors for extension authors following the SKILL.md examples; the field should be non-optional since it is always populated.
There was a problem hiding this comment.
Code Review
Well-structured fix for the additionalFiles directory flattening bug. The three-layer approach (push preservation, duplicate/symlink rejection at validation time, runtime resolver abstraction) is clean, and the 15 new tests provide solid coverage of the happy path, edge cases, and error modes.
Blocking Issues
None.
Suggestions
-
Hardcoded
PULLED_MARKERinextension_file_resolver.ts— The resolver hardcodes"/.swamp/pulled-extensions/"whileuser_model_loader.tscorrectly imports and usesSWAMP_DATA_DIRfromsrc/infrastructure/persistence/paths.ts. They resolve to the same string today, but using the shared constant would keep them coupled. Minor DRY nit, not blocking since the value is stable. -
Negative-result caching in
resolveExtensionFilesRoot— The walk-up cache only stores positive hits (found manifest). For models not shipped as extensions (built-in types, direct-content layouts), the walk repeats to the filesystem root on every load. Model loading isn't a hot path, so this is fine in practice, but cachingundefinedfor the initialdirname(sourcePath)after a full walk would eliminate the repeated I/O for those cases.
DDD Assessment
extension_file_resolver.tsis correctly placed as a stateless domain service insrc/domain/extensions/.extensionFilesRootonModelDefinitionis an appropriate runtime property — it describes where the model's deployment context resolves assets, populated at load time without schema migration.- The
resolveExtensionFilefunction encapsulates mode-aware (source vs pulled) error semantics as domain logic, keepingMethodContextandMethodReportContextfrom knowing about deployment layout. - Import boundaries are clean: domain layer has no libswamp imports; the libswamp roundtrip test imports from domain (established pattern); CLI/presentation code doesn't touch internal paths.
Other Observations
- Path traversal prevention via
isSafeRelativePathis correct — rejects..and absolute paths. - Symlink detection uses
lstat(notstat), so symlinks are caught before dereferencing. Good. - The parallel-array invariant assertion in
push.tsis a solid defensive check. - All new
.tsfiles include the AGPLv3 copyright header. - The synchronous
Deno.lstatSyncin the resolver is an intentional design choice (keepsextensionFile()sync for caller ergonomics) and appropriate for a single-syscall stat.
There was a problem hiding this comment.
Adversarial Review
I traced every code path in the 16 changed files, probed for path traversal, race conditions, data corruption, and resource leaks. The fix is well-targeted and thoroughly tested.
Critical / High
None.
Medium
-
src/domain/extensions/extension_file_resolver.ts:60— Windows pulled-mode detection inconsistency.
resolveExtensionFilechecksroot.includes("/.swamp/pulled-extensions/")without normalizing backslashes. Meanwhileuser_model_loader.ts:1514doescurrentDir.replace(/\\/g, "/")before the same marker check. On a Windows host, the resolver would misclassify a pulled extension as source-loaded, producing the wrong error message (pointing at a disk path + manifest instead of suggesting re-publish). Functionality (file resolution) is unaffected — only the error message diverges. PR description explicitly defers Windows; noting for when that work lands. -
src/domain/models/model.ts:357/src/domain/reports/report_context.ts:59—extensionFiletyped as optional but always populated.
buildMethodContext(line 129) andbuildMethodReportContext(line 212) unconditionally assign the closure, soctx.extensionFileis neverundefinedat runtime. Extension authors who writectx.extensionFile?.("path")expectingundefinedwhen no manifest exists will get a thrownUserErrorinstead. The docs say "throws if the model is not shipped as an extension", so behavior is documented — but the?:type annotation suggests the safer contract is "absent when unavailable". Not blocking since the thrown error is descriptive and the docs are clear.
Low
-
src/domain/extensions/extension_file_resolver.ts:56—resolveExtensionFile("", root)resolves to the root directory itself.
isSafeRelativePath("")returnstrue(no.., no leading/), andjoin(root, "")returnsroot.lstatSyncwould succeed on the directory, returning a directory path. A subsequentDeno.readTextFile(path)would fail with an OS error rather than a clearUserError. Unlikely in practice — extension authors would never pass an empty string — but a one-line guard (if (!relPath)) would turn this into a descriptive error. -
src/domain/extensions/extension_manifest.ts:33—isSafeRelativePathdoesn't reject Windows drive-letter prefixes (C:\foo).
On Linux this is harmless (becomes a literal path segment). On Windows,join(root, "C:\\foo")could resolve outsideroot. Explicitly out of scope per the PR, noting for completeness. -
src/domain/extensions/extension_file_resolver.ts:58— Runtime resolver doesn't reject symlinks.
Push-time validation (resolve_extension_files.ts:456) rejects symlinks inadditionalFiles, so archives won't contain them. However, if a symlink is manually created inside a pulled extension'sfiles/directory after extraction,resolveExtensionFilewould follow it. Theoretical — pulled directories are swamp-managed.
Verdict
PASS. The core fix (directory-preserving push via parallel-array zip, duplicate/symlink rejection, ctx.extensionFile() resolver) is correct, well-tested (15 new tests including a round-trip integration test), and properly wired through all buildMethodContext (3 sites) and buildMethodReportContext (4 sites) call paths. The parallel-array length invariant in push.ts is a good defensive check. No security vulnerabilities, no data-loss paths, no resource leaks.
- Make `MethodContext.extensionFile` and `MethodReportContext.extensionFile` non-optional. The factories (`buildMethodContext`, `buildMethodReportContext`) always populate them, and the SKILL.md examples call them without optional chaining — TS strict mode rejected that against `?:`. Test fixtures that inline-construct either context now include a throwing stub so the type still compiles. - Move `extensionFile` off `BaseReportContext` onto `MethodReportContext` only; `WorkflowReportContext` legitimately has no single model to resolve assets against. - Capitalize new error messages (`Extension file not found`, `Unsafe relative path`) to match the existing style in `resolve_extension_files.ts`. - Drop the internal `issue-146` reference from the pulled-mode missing- file error — re-publish/re-pull guidance stands on its own. - Reword the duplicate-entry hint to cover pure path-normalization duplicates where basenames already match. - Revert briefly attempted `SWAMP_DATA_DIR` import in `extension_file_resolver.ts` — would introduce a new domain→infrastructure dependency the DDD ratchet blocks. Use the hardcoded `.swamp/pulled-extensions/` literal with a comment pointing at the ratchet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Internal invariant message (
push.ts): The error"additionalFiles and additionalFilePaths length mismatch — this is a bug in extension file resolution."is thrown viavalidationFailedrather thanUserError. If a user ever sees it (they shouldn't under normal conditions), the phrase "this is a bug" could leave them wondering whether to file an issue or fix their manifest. Consider appending "Please report this at https://github.com/systeminit/swamp/issues." or similar. Not blocking — the invariant is a programmer guard that should be unreachable in practice. -
Source-mode missing-file message (
extension_file_resolver.ts, line ~629): UsesExtension file not found: ${absPath}(absolute path inline, no quotes). The duplicate-entries error wraps both offenders in double-quotes; consistency would give users the same scanning pattern here. Minor.
Verdict
PASS — No blocking issues. All new user-facing error messages (Duplicate additionalFiles, symlink rejection, ctx.extensionFile() failures) are clear, actionable, and correctly use UserError (no stack traces exposed). No new CLI flags or commands, so there's nothing to document in help text. The behavioral change (directory preservation on push) is transparent to command users and fixes the stated asymmetry without altering any output format or exit codes.
There was a problem hiding this comment.
Code Review
Clean, well-structured bug fix with strong test coverage and good DDD layering. The three-layer approach (push preservation, duplicate/symlink rejection, runtime resolver) is well thought out, and the mode-aware error messages are a nice UX touch.
Blocking Issues
None.
Suggestions
-
Negative cache miss in
resolveExtensionFilesRoot(src/domain/models/user_model_loader.ts:1499-1529): TheextensionFilesRootCacheonly caches positive hits (manifest found). For built-in models or non-extension source dirs, the walk-up to filesystem root repeats on every model load. Not a correctness issue — model loading isn't a hot path — but if many non-extension models are loaded, consider caching a sentinel value (e.g., empty string) for the negative case and checkingcache.has(key)instead ofcache.get(key) !== undefined. -
Synchronous fs in
resolveExtensionFile(src/domain/extensions/extension_file_resolver.ts:62-63):Deno.lstatSyncis used in the runtime resolver called during method execution. This is consistent withresolveExtensionFilesRoot(also sync) and the cost is one stat, so it's fine for now. If extension files ever resolve to network mounts, this could block the event loop — something to keep in mind for the Docker/remote source-mode follow-up (ADV-3). -
withTempRootinextension_file_resolver_test.ts: Two of the tests (unsafe relPath with '..'andabsolute relPath) create a temp directory viawithTempRootbut never actually use the filesystem — the assertions throw before any path resolution happens. Theawait Promise.resolve()at the end is there to satisfy the async signature. Minor, but these could be plain synchronous tests like theundefined roottest above them.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
src/domain/extensions/extension_file_resolver.ts:63—resolveExtensionFiledoes not verify the resolved path is a regular file.Deno.lstatSync(absPath)succeeds for both files and directories. If a model author callsctx.extensionFile("prompts/")orctx.extensionFile("prompts")(pointing at a directory), the function returns the path without complaint. The caller then gets an OS-level error ("Is a directory") fromDeno.readTextFileinstead of a typedUserError. This isn't a security issue — no escape occurs — but the error UX breaks the otherwise clean mode-aware messaging the PR introduces.- Breaking example:
ctx.extensionFile("prompts")whereprompts/is a directory inadditionalFiles. - Suggested fix: After the
lstatSync, checkinfo.isFileand throw aUserErrorif false:"Extension path is a directory, not a file: ...".
- Breaking example:
-
src/domain/models/user_model_loader.ts:1502-1528—resolveExtensionFilesRootwalk-up can match an unrelatedmanifest.yaml. The method walks from the model's source directory to the filesystem root. If an ancestor directory (e.g., the user's home directory or project root) contains an unrelatedmanifest.yaml, it would be found and treated as the extension manifest. This sets an incorrectextensionFilesRoot, causingctx.extensionFile()to resolve against the wrong directory. The blast radius is small — the file simply wouldn't exist and the user gets a missing-file error — but the error message would be misleading.- Breaking example: User has
~/manifest.yamlfrom an unrelated project; a source-loaded model at~/projects/myext/extensions/models/m.tshas no extension manifest of its own. The walk-up reaches~/manifest.yamland incorrectly setsextensionFilesRootto~. - Suggested fix: Bound the walk-up at
repoDir(already available on theUserModelLoaderinstance) rather than walking to filesystem root. This is a natural boundary since extensions live within a repo.
- Breaking example: User has
Low
-
src/domain/extensions/extension_file_resolver.ts:29vssrc/domain/models/user_model_loader.ts:1515— duplicated pulled-extension marker.extension_file_resolver.tshardcodesPULLED_MARKER = "/.swamp/pulled-extensions/"whileuser_model_loader.tsconstructs the same string from the importedSWAMP_DATA_DIRconstant. The code comment explains the DDD layer constraint, and the value is stable and tested. But ifSWAMP_DATA_DIRever changes, mode detection inresolveExtensionFilewould silently diverge from the loader. Extremely low probability given the test coverage onSWAMP_DATA_DIR. -
src/domain/models/user_model_loader.ts:1504-1506—extensionFilesRootCacheonly caches positive hits. When the walk-up finds no manifest (returnsundefined), intermediate directories are not cached. A project with many non-extension models would repeat the full walk-up for each one. This is purely a performance concern for an uncommon layout and has no correctness impact. -
src/cli/resolve_extension_files.ts:432— duplicate step number comment. Both step 15 (include files) and the new additionalFiles block are labeled// 15.. Cosmetic only.
Verdict
PASS — The core fix (preserving additionalFiles directory structure through push, rejecting duplicates/symlinks, adding the ctx.extensionFile() helper) is correct and well-tested. Path traversal is defended at two layers (Zod schema validation + isSafeRelativePath check in the resolver). The parallel-array invariant assertion in push.ts is good defensive practice. The 15 new tests cover the key edge cases (NFC/NFD collisions, case-folding, symlinks, missing files, round-trip preservation). The medium findings are UX polish opportunities, not blockers.
Summary
Closes swamp-club#146.
additionalFileswas flattened to basenames on push (src/libswamp/extensions/push.ts:1115-1118), dropping any declared subdirectory structure and silently clobbering entries that shared a basename. Pull's recursivecopyDirpreserves whatever's in the archive, so the asymmetry broke at push — contradictingdesign/extension.md:290("preserving their relative paths") and leaving authors to work around the bug by inlining assets as TS constants.The fix restores directory preservation at push, rejects duplicate-basename entries at push-time with a clear error, and introduces
ctx.extensionFile(relPath)so model and report code resolves bundled assets portably across source-loaded and pulled-mode extensions.What changed
Bug fixes
push.ts— preservesmanifest.additionalFilesrelative paths underfiles/via parallel-array zip withDeno.mkdirfor intermediate directories; asserts array-length invariant.resolve_extension_files.ts— rejects duplicate entries (NFC-normalized, case-folded for case-insensitive filesystem safety) and symlinks with typedUserErrors naming both offenders / explaining the rationale.Runtime helper
src/domain/extensions/extension_file_resolver.ts— shared resolver with mode-aware errors (pulled suggests re-publish; source points at absolute path and manifest).MethodContextandMethodReportContextboth gainextensionFile?: (relPath: string) => string.ModelDefinition.extensionFilesRootpopulated at load time byUserModelLoader— pulled extensions via path-slice (zero filesystem work); source extensions via walk-up tomanifest.yaml, deduped by a per-loaderMap<manifestDir, filesRoot>.buildMethodContextsites and all fourbuildMethodReportContextsites.Tests (15 new)
resolve_extension_files_test.ts— preservation, duplicates, case-fold, NFC/NFD, symlinks, 0-byte, missing file.extension_file_resolver_test.ts— undefined root, unsafe paths, mode-aware errors, valid resolution.push_pull_roundtrip_test.ts— end-to-end archive build → untar → resolver verification, hermetic (no registry calls).Docs
design/extension.md— Additional Files section expanded with helper and symlink rationale..claude/skills/swamp-extension-publish— directory-preservation note + helper section..claude/skills/swamp-extension-model—ctx.extensionFilein the context field list + reading-assets subsection showing recommended usage vs the hardcoded-path antipattern.Backwards compatibility
additionalFilesentries produce byte-identical archives —basename(\"README.md\") === \"README.md\". Common case is unaffected.copyDiris layout-agnostic).Out of scope
extensionFilesRootin source mode can resolve outsiderepoDir; pulled paths remain container-accessible).join()in push (tar normalizes archive paths on extract); CI is Linux-only.additionalFiles. Verified viasrc/libswamp/sources/add.ts— resolver correctly returns undefined for these.Follow-ups
Test Plan
deno check— cleandeno lint— clean (1066 files)deno run test— 4597 passed, 0 faileddeno run compile— binary rebuilt/tmp/swamp-repro-issue-146: model callingctx.extensionFile()resolves all 4 fixture files (flat + deeply nested)./prompts/review.md+prompts/review.md(normalized dup) andprompts/review.md+prompts/REVIEW.md(case-fold dup)🤖 Generated with Claude Code