Fix: require an explicit output directory and resolve the default in SkillToolset - #437
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: require an explicit output directory and resolve the default in SkillToolset#437AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
July 31, 2026 21:07
…SkillToolset materializeFiles defaulted `dir` to process.cwd(), so any caller that omitted the argument silently wrote agent-produced files into whatever directory the host process happened to be started from. Drop the default: `dir` is now required, and a call site that forgets it is a compile error (TS2554) rather than a silent write. The cwd default is not removed, only relocated to a place that declares it. SkillToolset resolves it in an `outputDir` getter, read per call so a host that changes its working directory is not pinned to the value captured at construction. With no `outputDir` configured, both skill tools write exactly where they did before. Stacked on fix/skill-script-output-dir, which added the outputDir option and the two-argument call sites.
Addresses two test-isolation hazards and trims two over-long doc comments. The unit tests shared one `testFile` object across three cases at describe scope. materializeFiles mutates `file.name` in place when it resolves a collision, so a shared fixture is a shared mutable object that only stays safe while the mock happens not to mutate it. Each case now builds its own. The configured-output-dir integration test asserted the absence of, and unconditionally deleted, `output_from_script.txt` in the working directory -- a name owned by the two neighbouring cwd tests. With a neighbour's file present it failed with `expected true to be false` and then deleted that file. It now runs a dedicated fixture script writing a name only it uses.
This was referenced Aug 1, 2026
Open
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
No existing issue.
Problem:
materializeFiles(files, dir = process.cwd())incore/src/utils/file_utils.tsmade "write into whatever directory the host process happens to be running in" the silent behavior of a shared utility. Any caller that omitted the second argument inherited the host's working directory without ever naming it, and nothing in the type system objected. Agent-produced file names come from an executed script (model-influenced data); the destination came from an implicit default.Solution: make the destination explicit, without changing where files actually land.
materializeFiles(files, dir: string)— the= process.cwd()default is dropped, sodiris required. The function body is untouched. A call site that forgets the argument is now a compile error (TS2554), not a silent write into the launch directory.SkillToolsetresolves it in anoutputDirgetter (this.configuredOutputDir ?? process.cwd()), read per call so a host thatchdirs is not pinned to the value captured at construction.outputDirconfigured, both skill tools write exactly where they wrote before, with the same_2/_3collision-suffix renaming reflected back intoresult.outputFiles.This PR is stacked on
fix/skill-script-output-dir(#298), not branched frommain.Collision check (run before writing any code, per contributor workflow):
gh pr list --repo AmaadMartin/adk-js --state open --limit 100 --json number,title,headRefName, thengh pr diff <n> --name-onlyon every plausibly adjacent PR. Five overlaps found in this cluster:SkillToolset.outputDiroption and the two-argument call sites, while deliberately keepingdir = process.cwd()inmaterializeFiles. That is an overlap, not a duplicate: it ships the knob and leaves the implicit default in place. This PR is therefore stacked on it and targets that branch, and contains only the residual delta (requireddir+ the getter). Please merge Fix: materialize skill script output into a declared directory, never process.cwd() #298 first. Nothing from Fix: materialize skill script output into a declared directory, never process.cwd() #298 is reimplemented here.dirrequired. It then goes further and stops writing toprocess.cwd()altogether whenoutputDiris unset, plus namespaces output under<outputDir>/<invocationId>/. That is a deliberate behavior change; this PR is the behavior-preserving alternative. These two are alternatives for the same base — they should not both merge. The choice is a product decision: whether an unconfiguredSkillToolsetshould keep writing to the launch directory (this PR) or write nothing (Fix: require an explicit destination directory so skill script output never lands in process.cwd() #353). This PR keeps the current behavior because three existing tests pin it as intended product behavior (see below).materializeFilesinput mutation / collision bounding), Fix: test path containment, not string prefix, in materializeFiles #371 (thestartsWithprefix containment defect), Chore: remove the accidentally committed output.txt from the repository root #429 (removes the stray tracked rootoutput.txt) — all touch adjacent lines but fix different defects. Not duplicated and not stacked on; each is left strictly alone here.Why the default destination is deliberately preserved. Writing skill-script output into the agent process's working directory is tested product behavior, not an accident — a local CLI agent generating files into the user's project directory is a real scenario. These three tests are the regression signal and are left completely untouched by this PR:
tests/integration/tools/run_skill_script_tool_test.ts—creates files in process.cwd returned from executiontests/integration/tools/run_skill_inline_script_tool_test.ts— the same assertion for the inline tooltests/integration/skills/script_js/agent_test.ts— a full agent scenario spawned withcwd: PROJECT_PATHthat asserts three generated files appear inPROJECT_PATHScope notes.
materializeFilesis not exported fromcore/src/index.tsorcore/src/common.ts, so requiringdiris an internal change with no downstream breakage, and it is not added to either barrel here.core/src/code_executors/unsafe_local_code_executor.tsalready passed an explicittempDirand is untouched.outputDiris host configuration, never a tool argument — it is not added to either tool's_getDeclaration(), so the model cannot influence where files are written. No new error codes: amaterializeFilesrejection still propagates into each tool's existingtry/catchand surfaces asEXECUTION_ERROR.outputDiris not validated at construction time, so an unwritable path fails at write time with the OS error rather than in a constructor that touches the filesystem. ThematerializeFilesdoc comment describes its containment check honestly as a lexical guard rather than a sandbox.Doc comments on
materializeFilesand theoutputDirgetter were trimmed in the second round: the required-dirrationale now lives once, on@param dir, and the getter keeps only the per-read note instead of repeating the option doc's first sentence and default. The lexical-guard / TOCTOU caveat is retained verbatim.No suppressions of any kind were added — no
any,as any,as never,@ts-expect-error,@ts-ignore,eslint-disable, or coverage-tool ignore, insrcor in tests.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.
Added (
core/test/tools/skills/skill_toolset_test.ts):defaults to the process working directory— pins that an unconfigured toolset resolves toprocess.cwd(), i.e. that the relocated default is really still the cwd.resolves the working directory on each read, not at construction— constructs the toolset, then movesprocess.cwd(), and asserts the getter follows. This is what makes the getter equivalent to the old call-time default rather than a construction-time snapshot.keeps a configured directory when the process working directory moves— the negative of the above: a configuredoutputDirmust ignore cwd entirely.Existing tests changed — declared explicitly. All four are tests #298 added on the branch this is stacked on; none of them exists on
main, and none of the three cwd regression tests named earlier is touched.core/test/tools/skills/run_skill_script_tool_test.tstoHaveBeenCalledWith([testFile], undefined)toHaveBeenCalledWith([testFile], process.cwd())materializeFilesto the getter, so the argument is now the path instead ofundefined. Same behavior asserted.core/test/tools/skills/run_skill_inline_script_tool_test.tscore/test/tools/skills/skill_toolset_test.tsis undefined when no directory is configured→toBeUndefined()defaults to the process working directory→toBe(process.cwd())outputDiris now a getter that never returnsundefined.core/test/utils/file_utils_test.tsshould default the base directory to the working directory of each callmaterializeFiles(files)with one argument, which no longer compiles. Its intent — cwd resolved per call, not snapshotted — is not lost: it is carried by the newresolves the working directory on each read, not at constructioncase, at the location where that behavior now lives. Verified to still fail on mutation M4 below.Test-isolation fixes (second review round). Two hazards were found in tests #298 added on the stack base; both are fixed here rather than deferred to the base PR, since this PR already edits those cases:
run_skill_script_tool_test.ts,run_skill_inline_script_tool_test.tstestFileobject todescribescope, shared by three cases.materializeFilesmutatesfile.namein place when it resolves a collision (core/src/utils/file_utils.ts:59), so a shared fixture is a shared mutable object, safe only while the mock happens not to mutate it.'utf8' as FiletoFileContentEncoding.UTF8, which removes a cast.tests/integration/tools/run_skill_script_tool_test.tsoutput_from_script.txtin the cwd and unconditionallyfs.rm'd it infinally, but that name is owned by the two neighbouring cwd tests (one writes then unlinks it with nofinally, the next pre-creates it). A failure in the preceding test made this one fail spuriously and destroyed the neighbour's file.create_file_for_output_dir.jswritingoutput_to_configured_dir.txt, a name only this test uses. The sibling inline-script case already used a uniquetest_output_${Date.now()}.txtand is unchanged.Verified both directions on the integration hazard, with a neighbour's file planted in the cwd:
x creates files in the configured output directory -> expected true to be false, and the planted file was deleted by the test'sfinally.Per the spec's guidance, no test asserting "the argument is mandatory" was added: that is a compile-time property and
tsc --noEmitproves it directly (mutation M1). No test was skipped, disabled,.only'd, or weakened.Proof each test can fail. Every assertion was run against mutated source and observed to fail.
await materializeFiles(result.outputFiles)core/src/tools/skill/run_skill_script_tool.ts(145,34): error TS2554: Expected 2 arguments, but got 1.A missed call site can no longer be a silent cwd write.return process.cwd();exposes the configured directoryandkeeps a configured directory when the process working directory moves; both tools'materializes output files into the configured output directory→expected "spy" to be called with arguments: [ …(2) ]; integrationcreates files in the configured output directory→ENOENT: no such file or directory, open '/tmp/adk-skill-output-vvwle3/output_from_script.txt'and…/test_output_1785557155050.txt.?? '/tmp/not-the-cwd'defaults to the process working directory;resolves the working directory on each read…; both tools'calls materializeFiles with output files from executor.resolvedOutputDir = options.outputDir ?? process.cwd()in the constructor)file_utils_testone:resolves the working directory on each read, not at construction→expected '/usr/local/google/home/…' to be '/tmp/skill-output-after-chdir'.Coverage. Measured on the two changed source files:
Every uncovered line was cross-referenced against
git diffand none is a line this PR adds:skill_toolset.ts:79is the pre-existing skills-normalization ternary,123-124isgetSkill,207-209is atoolCachepath;file_utils.ts:69-72is the pre-existing second traversal check (a separate defect, out of scope). Both branches of?? process.cwd()are exercised. The per-file percentages are diluted by that pre-existing untested code, not by new code.Type/lint checks on the exact pushed commit:
npm run buildandnpm run lintboth exit 0.npm run ts:checkis red on this repo today for pre-existing reasons; the error profile was captured on the stack base and on this branch and diffed — 304 errors on both, byte-identical — so this change introduces zero new type errors.CI: this PR targets
fix/skill-script-output-dir, and every workflow in.github/workflows/is gated onpull_request: branches: [main], sorun-testsdoes not trigger for a stacked base. The commands above were therefore run locally against the exact pushed commit.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
The integration suites above are the automated form of this, driving the real
UnsafeLocalCodeExecutoragainst the real filesystem with no mocks. To reproduce by hand:scripts/create_file.jsisconst fs = require('fs'); fs.writeFileSync('report.csv', 'a,b');, then from any directory (cd ~/my-project && node agent.js) build the toolset without an output directory and invokerun_skill_script:report.csvappears in~/my-project— unchanged from before this PR. That is the point: the default did not move.report.csvnow appears at/tmp/skill-output/report.csv,~/my-projectstays clean, andresult.outputFiles[].nameremains relative to the destination.outputDirat a directory and have the script write../escape.txt. The existingPath traversal detectederror still surfaces through each tool's existing catch block asEXECUTION_ERROR; no new error type or code was added.tests/integration/skills/script_js/agent_test.tsis the end-to-end check for the unchanged default. Note: it cannot run in this sandbox — itsbeforeAllhook shells out tonpm installin the fixture project and exceeds the hook timeout on a restricted network. This was confirmed to fail identically on the unmodified base commit (git stash→ samebeforeAll/npm installhook timeout,Tests 1 skipped), so the failure is environmental and pre-existing, not caused by this change. The file is not modified by this PR.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.