Fix: keep non-UTF-8 skill resources as Buffers in the skill loaders - #361
Open
AmaadMartin wants to merge 1 commit into
Open
Fix: keep non-UTF-8 skill resources as Buffers in the skill loaders#361AmaadMartin wants to merge 1 commit into
AmaadMartin wants to merge 1 commit into
Conversation
Buffer.prototype.toString('utf-8') never throws: it replaces invalid byte
sequences with U+FFFD. Both decode sites in the skills loader wrapped it in a
try/catch that could therefore never fire, so a binary resource was stored as
an unrecoverable mojibake string instead of a Buffer, and the Buffer arm of
the published Resources type was unconstructible by the loaders themselves.
Decode with a fatal TextDecoder instead and fall back to the raw bytes, so
LoadSkillResourceTool takes its inlineData path and getSkillResourceFiles
base64-encodes the true bytes. ignoreBOM: true keeps a leading U+FEFF, which
preserves today's output for BOM-prefixed text.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Link to an existing issue (if applicable):
No existing public issue; found while reading
core/src/skills/loader.ts.Or, if no issue exists, describe the change:
Problem: Both decode sites in the skills loader are wrapped in a
try/catchthat can never fire:Buffer.prototype.toString('utf-8')never throws in Node — invalid byte sequences are silently replaced with U+FFFD. So thecatchwas dead code, and a binary skill resource (the canonical case being a PNG underassets/) was stored as a lossy mojibake string whose original bytes are unrecoverable. Consequences, all silent:Resources.references/assetsis declaredRecord<string, string | Buffer>(core/src/skills/skill.ts), but neither loader could ever produce theBufferarm — a published type whose second member was unconstructible by the library's own loaders.LoadSkillResourceToolbranches onBuffer.isBuffer(content). That branch was unreachable for directory- and zip-loaded skills, so instead of injecting the bytes as aninlineDatapart it returned the corrupted text ascontent.getSkillResourceFiles(core/src/tools/skill/run_skill_script_tool.ts) doesBuffer.from(fileContent).toString(encoding); for a.pngthe resolved encoding is base64, so the code executor received base64 of the mojibake re-encoding and materialised a corrupt file.scripts/passed thetypeof src === 'string'filter as mojibake and was registered as a runnable script with corrupted source.Solution: decode with a fatal
TextDecoderand fall back to the raw bytes, via one unexported module-level helper called from both sites:Notes on the choices:
ignoreBOM: trueis load-bearing. The flag name is inverted from how it reads: it means "do not strip a leading U+FEFF".TextDecoderstrips the BOM by default whileBuffer.toString('utf-8')does not, so without it every BOM-prefixed text resource would silently change content. There is a dedicated test for this, and it fails without the flag (see the mutation evidence below).decode()call keeps no state between calls, including after it throws.core/src/utils/would add a module plus a test file and make the diff longer, not shorter._load_dirand_load_zip_dirinsrc/google/adk/skills/_utils.pycatchUnicodeDecodeErrorand skip the entry entirely. This PR keeps the bytes instead, because (1) the TS type already promisesstring | Buffer, and theloadDirdoc comment already says "as string for UTF-8 or Buffer otherwise" — this is a docs-vs-code mismatch, not a design change; (2) the consumer is already written and tested for aBuffer; (3) keeping the bytes loses no information, whereas skipping makes the user's PNG vanish with no diagnostic; (4) parity holds where it matters — adk-python's own resource model isdict[str, str | bytes], its GCS loader (_load_files_in_dir) already keeps the bytes onUnicodeDecodeError, and its toolset already handlesbytes; only the directory and zip loaders skip. Aligning those two Python loaders is left to a follow-up and no Python file is touched here.Behaviour change under an unchanged public signature (no
.d.tschange, no new exports): a caller that previously received a mojibakestringfor a binary resource now receives aBuffer.LoadSkillResourceToolnow takes its binary branch,getSkillResourceFilesnow base64-encodes the true bytes, and a binary file underscripts/is no longer registered as a runnable script (this last one matches adk-python_load_dirand is a strict improvement over executing mojibake).Collision check:
gh pr list --repo AmaadMartin/adk-js --state open --limit 100plusgh pr diff --name-onlyon every plausibly adjacent PR. Six open PRs also touchcore/src/skills/loader.ts(#262, #263, #283, #284, #310, #312), but none of them changes either decode site — grepping their diffs forTextDecoder,toString('utf-8'),decodeUtf8,Buffer.isBuffer,fatal:andignoreBOMreturns nothing in all six. This is overlap in the same file, not a duplicate implementation, and since the six are independent siblings each branched frommainthere is no single branch to stack on; this PR branches frommainand touches only the two decode statements plus additive tests, so it should rebase cleanly against whichever of them lands first.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
10 new
itblocks incore/test/skills/loader_test.ts. The hunk is purely additive — no existing test's fixtures or assertions were edited, so the existingloads resources if they existcase still pins the UTF-8 text path.describe('loadSkillFromDir'): keeps an invalid UTF-8 asset / reference as a Buffer; keeps a truncated multi-byte sequence (e2 82, an incomplete€) as a Buffer; drops an invalid UTF-8 script while a siblingok.shsurvives; decodes multi-byte UTF-8 (héllo — ✓ 🎉) as a string; preserves a leading UTF-8 BOM.describe('loadSkillFromZipBuffer')(new block, archives built withAdmZip): the same asset / reference / script-drop / multi-byte cases. Buffer cases assert bothBuffer.isBuffer(...)and byte equality with the original —Buffer.isBufferalone would also pass against a re-encoded buffer, so the equality is what pins losslessness.New-code coverage is 100% line and branch: measured with
--coverage.include='core/src/skills/loader.ts', the uncovered statement lines are90,91,92,94,98,99,151,152,224..230,258,305,306,329,330,367,368and uncovered branch lines89,97,150,223,228,256,304,328,359,366,379,381,383,385— none of which is the new decoder (55,65-70) or either new call site (102,386). The remaining loader.ts gaps are pre-existing.Proof the tests can fail (mutation testing). Coverage is a floor, so each new test was run against mutated source and confirmed to fail:
loadDircall site tofileData.toString('utf-8')→ 4 failures:keeps an invalid UTF-8 asset as a Buffer→AssertionError: expected false to be true // Object.is equalitykeeps an invalid UTF-8 reference as a Buffer→ samekeeps a truncated multi-byte sequence as a Buffer→ samedrops an invalid UTF-8 script→AssertionError: expected { src: '\ufffd\ufffd\ufffd' } to be undefinedloadZipDircall site → 3 failures: the two zip Buffer cases withexpected false to be true, anddrops an invalid UTF-8 scriptwithexpected { src: '\ufffd\ufffd\ufffd' } to be undefined.ignoreBOM: truefrom the decoder → 1 failure:preserves a leading UTF-8 BOM in text content→AssertionError: expected 'hello' to be '\ufeffhello' // Object.is equality.decodes multi-byte UTF-8 content as a stringcases (dir + zip) pass both before and after the fix — they are regression guards against an over-eager fallback that would demote all non-ASCII text toBuffer, so they are deliberately not killed by mutations 1-3. They are not unfalsifiable: mutating the helper to always return the raw bytes fails them withexpected Buffer[ 104, 195, 169, ... ] to be 'héllo — ✓ 🎉'(that mutation also fails the pre-existingloads resources if they existcase, confirming the text path is guarded).Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Run against the built package (
npm run build) with no mocks, using a real 1x1 PNG whose magic bytes89 50 4e 47are not valid UTF-8:SKILL.mdwithname: bin-skill, plusassets/logo.pngcontaining the real PNG bytes.const skill = await loadSkillFromDir(dir)→ assertBuffer.isBuffer(skill.resources.assets['logo.png'])and that it.equals()the original PNG bytes.AdmZipand repeat withloadSkillFromZipBuffer(zip.toBuffer())→ same two assertions.new LoadSkillResourceTool(new SkillToolset([skill])).runAsync({args: {skill_name: 'bin-skill', path: 'assets/logo.png'}, toolContext}).Result on this branch: both loaders return the exact PNG bytes as a
Buffer, and step 4 returnsi.e. the tool's binary path is reachable from a real skill directory for the first time. Against the unfixed loader the same script aborts at step 2 with
AssertionError: dir: asset must be a Buffer.Other gates run locally on the pushed commit:
npm run build✓,npm run lint✓,npm run format:check✓.npm run ts:checkis not part of CI and fails identically with and without this change (308 pre-existing errors, byte-identical set of failing files, none of themcore/src/skills/loader.tsorcore/test/skills/loader_test.ts).CI note: the
run-tests (windows-latest)leg failed twice on rerun before going green, each time on a different set of integration tests (tests/integration/app_loader/app_loader_test.ts,tests/integration/build_setup/build_setup_test.ts) and always withTest timed out/Hook timed outin abeforeAllthat runsnpm installin a fixture — never with an assertion failure, and never in a skills test. The samebeforeAlltimes out locally on an unmodified checkout, i.e. it fails before any code from this PR can execute. ubuntu, macOS and the cross-language leg passed on the first attempt; all legs are green now. Raising those fixture-install timeouts is unrelated churn for this PR and is already the subject of separate PRs.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.