Repository navigation
Add on-demand source and symbol package retrieval - #13
Conversation
SShadowS
left a comment
There was a problem hiding this comment.
Review
Reviewed on a clean worktree of 1cfc265, plus a second independent pass from another model with live measurements. Verified locally: bun install --frozen-lockfile clean, bun run typecheck clean, bun run build clean with zero embed-skills drift, git status clean after build.
bun test reports 644 pass / 0 fail across 44 files on Windows, not the 645 in the PR description and README badge. Not a miscount: tests/core/package-download.test.ts:403 gates the symlink test behind process.platform !== "win32", so the dated evidence file's 645 was a non-Windows run. That matters for a reason beyond the badge, see finding 3.
This is careful work on a genuinely dangerous surface. Most of what looks scary here is actually handled, so the explicit clean verdicts are listed too, to save the next reviewer from re-deriving them.
1. inflateRawSync has no output cap on a newly untrusted path
src/core/snapshot/zip.ts:69, reached from packageIdentity at src/core/package-download.ts:166.
The download itself is bounded to 256 MiB (:242-275), and the identity is validated before anything is written. But the inflation of SymbolReference.json is unbounded: Node's default maxOutputLength is the 4 GB Buffer limit.
Concrete scenario: on-prem dev endpoints are plain HTTP (the project's own live box is http://Cronus28:7049). A MITM or a compromised server returns a crafted NAVX whose DEFLATE entry inflates toward 4 GB. zlib allocates multi-GB RSS before ERR_BUFFER_TOO_LARGE finally lands as a typed PROTOCOL_ERROR. On a constrained box (CI, container) the MCP server process is OOM-killed first, taking any live debug session or run lock with it.
zip.ts is pre-existing and unchanged by this PR, but this PR is what starts feeding it a 256 MiB network archive. The PR already treats the server as untrusted enough to bound the archive and cross-check the identity, so leaving the inflation unbounded is internally inconsistent. Fix is one line: thread a maxOutputLength through extractEntry into inflateRawSync (512 MiB, or 8x maxBytes), plus one test.
2. WIRE provenance cites a reference directory that does not exist
package-download.ts:100-102 and :232-235 cite al-codeanalysis-decomp (SymbolReferenceSpecification.IsApplicationConceptReference, NavAppManifest.cs, FileNameUtilities.cs). reference/al18-decompiled/ contains only almcp-decomp, dep-decomp, esp-decomp, lmt-decomp, tw-decomp. These are the only two citations in the whole of src/ pointing at a nonexistent directory, so the project's provenance rule is not satisfied for either.
The practical consequence is a behaviour nobody can now check: whether IsApplicationConceptReference is name-only case-insensitive, as :89 and :103 implement, or also requires publisher Microsoft. If the latter, a third-party app literally named "Application" silently loses appId enforcement. Impact is negligible because the publisher/name/version cross-check still holds, but a deviation from the official client should be knowable rather than inferred.
Separately, :109 claims "Validated live on BC28 on-prem 2026-07-04" while the dated evidence file covers SaaS 2026-07-30 only. The on-prem half of that claim has no listed evidence.
3. The backup-swap branch has zero automated coverage on any platform
replaceValidatedFile:343-351.
POSIX rename replaces atomically, and Windows fs.rename uses MoveFileEx with REPLACE_EXISTING, so the replaced tests never enter the EEXIST/EPERM path on either platform. It only fires on a locked or read-only destination: alc.exe holding the .app open, or antivirus. That is precisely the situation where the restore-on-failure path and the orphaned .backup cleanup matter, and neither is pinned. rename is imported directly, so exercising it needs either a small injection seam or mock.module.
Related, and the more useful half: the symlink test at :403-414 is skipped on win32, which is this project's stated primary dev platform. The guard itself does work there. A junction is creatable with zero privileges (New-Item -ItemType Junction) and Node's lstat().isSymbolicLink() returns true for one, so :378-380 rejects it. Replacing the platform skip with a junction-based test would pin the guard where it is most likely to be needed, and would also reconcile the 644/645 count.
4. Nits
- No length cap on
publisher/appName, so an absurdly long name fails late as aCONFIGURATION_ERRORon MAX_PATH rather than as validation. :463: theNOT_FOUNDdetails reportappId: nullfor the Application concept even when the caller supplied one. Accurate about the request, potentially confusing about the input.:452-466: a pre-2.0 server missing thedev/packagesroute 404s into "No installed Business Central package matched ...", conflating route-absent with package-absent.bcdev_sourcedocuments exactly this conflation atsource-content.ts:56-58; this path does not.installPackage:376-384wraps the mkdir, the lstat, and the symlink rejection in one try, so a rejected symlinked.alpackagessurfaces as "Unable to create the AL package directory", which is misleading.- A crash in the window between the two renames in
replaceValidatedFileleaves the package parked at.backupand absent from.alpackagesuntil re-download. Self-healing, worth a comment. maxBytesandtimeoutMsarePackageDownloadOptionsbut are not exposed on the tool schema, so an agent hitting either bound has no recourse.
Checked and found safe
Listing these explicitly so they do not get re-raised.
- Filename derivation is safe, and stronger than the official client's.
packageFilename:237strips exactly .NET's WindowsPath.GetInvalidFileNameCharsset (controls plus<>:"/\|?*), so no separators and no ADS colon survive. The server can only vary the case of publisher and appName, becausesameText:200cross-checks both against the caller's selector before the filename is built, and the version is forced throughparseVersion:196to\d+\.\d+\.\d+\.\d+. The name therefore always ends_d.d.d.d.app, so it can never be.., never a bare Windows device name (underscores are always present), and never ends in a dot or space. Traversal is impossible. For contrast,dep-decomp PackagesApiClient.DownloadPackagedoesPath.Combine(directory, ContentDisposition.FileName)with the server's unsanitized filename; the identity-derived naming here is a deliberate improvement and is worth keeping. - Memory is fine for legitimate packages, measured rather than estimated. Against the real
Microsoft_Base Application_28.0.46665.47126.app(43.7 MB):SymbolReference.jsoninflates to 55.6 MB,JSON.parsetakes 239 ms, peak RSS 425 MB, peakheapUsedabout 120 MB, since the object graph is dropped immediately after the four scalars are read. Worst legitimate transient at the 256 MiB cap is roughly 770 MB of off-heap buffers. The only unbounded axis is finding 1. (There is a redundant full-buffer copy atzip.ts:56,Buffer.from(zip)on an already-Buffer, but at these sizes it is not worth a change on its own.) withInstallLock:354-369cannot deadlock or poison the chain.release()runs infinally, so every link resolves regardless of whether the operation rejects, and by inductionawait previousnever throws. Map entries are deleted when the finishing tail is still current, so growth is bounded by in-flight distinct paths. Cross-process is unguarded but outcome-safe: temps arewxplus UUID, renames are atomic, and both writers install only fully validated bytes, so the worst case is an orphaned.tmp/.backupthatalcignores.- No credential or URL leak.
agentErrorBody(src/mcp/agent-errors.ts:103-116) serializes only code, category, message, retryable, tool, and details, so the{ cause }attached atpackage-download.ts:487,496never reaches the MCP body. The message passesredactAuthorization, details are key- and value-redacted, and theENDPOINT_UNREACHABLE/TIMEOUTmessages contain no URL by construction. Thesource-content.tschange correctly hoistsgetAuthorizationHeader()out of the try so typed auth failures are no longer swallowed into endpoint errors, and drops${url}/${err}from the unreachable message. Both are pinned by tests. - Hostile zip input other than finding 1 is handled. Negative
archiveBase, oversizedcentralSize, zip64 sentinels, and truncated entries all end in a BufferRangeErroror an explicit throw, wrapped asPROTOCOL_ERRORat:168with no partial install, and the corrupt-archive tests assert no temp or package residue.findEocd's backward scan over a 256 MiB non-zip 200 response measures 520 ms, which is acceptable. No silent-misparse path found. - Tool wiring is correct. The output schema matches
PackageDownloadResultfield for field andoutputSchema.parse(result)is pinned intools.test;projectresolves through the sameresolve()as every other tool. - Tests are meaningful, not happy-path. The size cap is covered on both the content-length and the streamed path, and corrupt archive, missing symbols, bad JSON, identity and version mismatch, authorization ordering, abort signal, and no-request-on-bad-project are all pinned with real assertions. The gaps are exactly finding 3, plus no bomb case (which finding 1's fix should bring with it) and no DEFLATE-method fixture, since
tests/fixtures/app-package.tsbuilds STORE-only archives and the real.appDEFLATE path is therefore only exercised in live E2E.
Merge gate
- Borderline block: finding 1. The failure mode is process OOM rather than compromise, so it is not a hard block, but it is a one-line hardening on the new untrusted-input path this PR introduces, and the PR's own threat model already implies it.
- Should fix: finding 2 (re-cite or add the directory to the documented layout, and reconcile the on-prem evidence claim at
:109), finding 3 (junction-based test on win32, and an injection seam or mock for the backup-swap branch). - Nits: everything in section 4.
One cross-PR note
#12 and this PR both take the tool count from 22 to 23, and both rewrite the test badge. Whichever merges second will be wrong on both (the count should end at 24, and the badge at the combined total). Worth resolving deliberately rather than in a conflict resolution.
|
Heads-up: #12 merged as Beyond the mechanical conflict resolution, four things to get right: 1. Tool count is now 24, not 23. Both PRs independently wrote 22 to 23. After this one lands the real count is 24. Three places:
The 2. Test badge. #12 landed at 641. This branch's 645 was measured against the old base, so the post-rebase number will be different again. Worth re-running rather than arithmetic, and note the count is platform-dependent (see below). 3. 4. Overlapping-file conflicts to expect: None of this changes the review verdict already posted. The open items from that review are still finding 1 ( On finding 3 and the badge: the 645 was a non-Windows run. |
|
Resolved the review in
Verification on the pushed head: 649 tests passed, typecheck passed, build passed, and embedded-skill drift passed. The review corrections were deterministic and made no additional Sandbox call; the existing redacted SaaS evidence remains the source for the wire behavior. |
906eecf to
c053e9f
Compare
|
Rebased the corrected branch onto merged #12 and force-pushed
The PR description and test badge now reflect the rebased tree. The source/symbol review corrections remain unchanged and covered after the rebase. |
Re-review at
|
Follow-up cleanup for #13. - Keeps a validated package replacement successful when stale backup cleanup fails, returning a nonfatal `warning` in the result. - Caps inflated `SymbolReference.json` data at the active runtime's safe text limit. - Applies the same bounded extraction to sampling profile data, which was previously unbounded. - Types malformed archive reads as `PROTOCOL_ERROR` rather than `INTERNAL_ERROR`. - Updates the package response schema, guidance, embedded skill, and deterministic regression coverage. Verified on the merge result: typecheck clean, 670/670 tests pass, no embedded-skill drift. Claude-Session: https://claude.ai/code/session_0187Px3Vu7Nk4UgqjwbzDef1
Closes the open on-demand-symbols E2E item, which had SaaS Sandbox evidence only because no on-prem target was reachable when #13/#14 landed. Ran core downloadPackage over the real fetch against a local BC28 container: the Microsoft/Application concept package resolved from a 1.0.0.0 minimum without a supplied app ID, installed under an identity-derived filename, and the repeated call returned unchanged at the same digest. Also records that dev/packages 401s without a tenant query parameter on a single-tenant on-prem server, matching the existing hub negotiate behaviour. Claude-Session: https://claude.ai/code/session_0187Px3Vu7Nk4UgqjwbzDef1
Summary
This PR completes roadmap item 9: On-demand source & symbols.
bcdev_sourcegets the deployed AL source for one object and keeps the existing REST/debugger-hub behavior.bcdev_package_downloadgets one installed dependency package and writes it to the current AL project's.alpackagesdirectory.Package behavior
GET dev/packagesrequest. It does not list applications, resolve transitive dependencies, or synchronize all symbols.app.jsonbefore authorization or the request.SymbolReference.jsoninflation has an independent 512 MiB allocation cap.SymbolReference.json.unchangedfor the same bytes andreplacedfor different validated bytes.appIdonly for the live-validated MicrosoftApplicationconcept; third-party apps with that name keep ordinary app-ID selection.Compatibility and error handling
bcdev_sourceinput and result fields stay unchanged.dev/packages; details retain a supplied app ID and report whether it was sent..alpackagesmust be a direct directory, not a symlink or Windows junction.Validation
bun test: 668 passed, 0 failedbun run typecheck: passedbun run build: passedThe built MCP server was also tested against a Business Central SaaS Sandbox. The test covered:
All Business Central calls in the live test used GET. The test wrote packages only to a temporary local AL project and removed that project afterward. No Production calls were made.
Post-review hardening was deterministic and made no additional Sandbox call: bounded DEFLATE output, backup-swap success/restoration, Windows junction coverage, adjustable bounded limits, selector length validation, and corrected error details/provenance.
Redacted evidence is in
scripts/e2e-on-demand-source-symbols-2026-07-30.md.Integration
This branch is rebased onto roadmap item 6 after #12 merged. The combined tree exposes 24 tools, serves four embedded skills, reports the measured 668-test total, and reuses the shared AL identifier normalization utility.