Skip to content

[Bug]: Template copy() includes files that .dockerignore / fileIgnorePatterns should exclude (JS + Python) #1902

Closed

Description

@hardness1020

Sandbox ID or Build ID

N/A. Client-side bug, reproducible offline without building anything.

Environment

  • e2b (JS) 2.51.0 and e2b (Python) 2.51.0, current main (ccaf9fc0)
  • glob 13.0.6, wcmatch 11.0.1
  • macOS 26.5.2, Node 24.12.0, Python 3.14.2

Timestamp of the issue

2026-09-25 UTC (not time-dependent)

Frequency

Happens every time

Expected behavior

Paths matched by .dockerignore or fileIgnorePatterns (file_ignore_patterns in Python) stay out of the file set that copy() hashes and uploads, as they do in a Docker build context:

  • Patterns apply no matter how src is written (., ./, ./src, src/., dist/../src, src), and the context root itself is never excluded.
  • A pattern that matches a directory (node_modules, .git, dist/, src/generated, **/node_modules) excludes everything under it.
  • A leading / is ignored, as in Docker (/node_modules is the same as node_modules).

The docs say the same: files matching .dockerignore and fileIgnorePatterns "are excluded from uploads and hash calculations" (Base image). Their example, fileIgnorePatterns: [".git", "node_modules"], is hit by bug 2 below (and bug 1 in Python) and uploads both directories' contents with copy('.').

Actual behavior

The shared collector (getAllFilesInPath in JS, get_all_files_in_path in Python) has three bugs. The files hash and the upload tar both use its output.

  1. Python only: if src is . or has a . or .. segment (., ./, ./src, src/., dist/../src with an existing dist), an ignore pattern matches only if it repeats that segment (for ., ./ and ./src, it must start with ./). So .env, node_modules and even **/*.spec.* are skipped, and copy('.') uploads every file they ignore. A .* pattern matches the root . itself, so copy('.') fails with No files found. JS normalizes these src forms and keeps the root.
  2. Both SDKs: when a copied directory is walked (copy('.'), copy('src')), a pattern that matches a subdirectory (node_modules, .git, dist/, src/generated, **/node_modules) drops only that directory entry, not its contents. A bare pattern works only when src itself matches the directory (copy('*'), copy('src/*')). Otherwise the contents are excluded only by a pattern that matches them directly, such as node_modules/**. In Python, bug 1 masks this for copy('.'); copy('src') shows it.
  3. Both SDKs: a root-relative leading / (/node_modules, /node_modules/**) matches nothing, not even the directory itself.

Also, a . or ./ ignore line (which Docker ignores) makes every JS copy() step throw TypeError: empty pattern list when the template is built or serialized, and in Python it excludes the root, so copy('.') fails with No files found.

Impact:

  • Files inside ignored directories (host node_modules, .venv, .git, a secrets/ dir) are uploaded into the template image in both SDKs. In Python, copy('.') uploads every ignored file, including .env (unless the pattern is written ./.env). Anyone who can start a sandbox from the template can read them: the team, or everyone once it is made public with e2b template publish.
  • fromDockerfile() (and e2b template create) turns COPY . /app into copy(), so Dockerfile users with a typical .dockerignore are affected too.
  • Ignored files under .git (index, refs, logs) are part of the files hash. Every commit changes the filesHash of copy('.'), so the SDK re-uploads the context and the build cache misses from that step on. Later steps (for example package installs after the copy) re-run.

Issue reproduction

Run everything from the root of an e2b-dev/E2B checkout (after pnpm install). Build context:

mkdir -p ctx/node_modules/pkg ctx/src/generated ctx/.git && cd ctx
printf '.env\nnode_modules\n.git\n**/*.spec.*\nsrc/generated\n' > .dockerignore
echo SECRET=1 > .env
echo x > node_modules/pkg/index.js
echo x > src/app.ts
echo x > src/app.spec.ts
echo x > src/generated/api.ts
echo ref > .git/HEAD
cd ..

Python (save as repro.py, run uv run --project packages/python-sdk python repro.py):

import json, os
from e2b import Template
from e2b.template.utils import get_all_files_in_path, read_dockerignore

ctx = os.path.abspath("ctx")
ignore = read_dockerignore(ctx)
for src in [".", "./src", "src"]:
    files = get_all_files_in_path(src, ctx, ignore, True)
    print(f"{src!r:8}", sorted(os.path.relpath(f, ctx) for f in files))

def files_hash():
    t = Template(file_context_path=ctx).from_image("node:22").copy(".", "/app")
    return json.loads(Template.to_json(t))["steps"][0]["filesHash"]

before = files_hash()
open(os.path.join(ctx, ".git/HEAD"), "a").write("x")
print("filesHash changed after editing ignored .git/HEAD:", before != files_hash())
'.'      ['.', '.dockerignore', '.env', '.git', '.git/HEAD', 'node_modules', 'node_modules/pkg', 'node_modules/pkg/index.js', 'src', 'src/app.spec.ts', 'src/app.ts', 'src/generated', 'src/generated/api.ts']
'./src'  ['src', 'src/app.spec.ts', 'src/app.ts', 'src/generated', 'src/generated/api.ts']
'src'    ['src', 'src/app.ts', 'src/generated/api.ts']
filesHash changed after editing ignored .git/HEAD: True

JS (save as repro.mts, run pnpm dlx tsx repro.mts):

import * as fs from 'node:fs'
import * as path from 'node:path'
import { Template } from './packages/js-sdk/src/template'
// internal helpers
import { getAllFilesInPath, readDockerignore } from './packages/js-sdk/src/template/utils'

const ctx = path.resolve('ctx')
const ignore = readDockerignore(ctx)
for (const src of ['.', './src', 'src']) {
  const files = await getAllFilesInPath(src, ctx, ignore, true)
  console.log(JSON.stringify(src).padEnd(8), JSON.stringify(files.map((f) => path.relative(ctx, f.fullpath()) || '.').sort()))
}

const filesHash = async () => {
  const t = Template({ fileContextPath: ctx }).fromImage('node:22').copy('.', '/app')
  return JSON.parse(await Template.toJSON(t)).steps[0].filesHash
}
const before = await filesHash()
fs.appendFileSync(path.join(ctx, '.git/HEAD'), 'x')
console.log('filesHash changed after editing ignored .git/HEAD:', before !== (await filesHash()))
"."      [".",".dockerignore",".git/HEAD","node_modules/pkg","node_modules/pkg/index.js","src","src/app.ts","src/generated/api.ts"]
"./src"  ["src","src/app.ts","src/generated/api.ts"]
"src"    ["src","src/app.ts","src/generated/api.ts"]
filesHash changed after editing ignored .git/HEAD: true

JS ("." row: .git, node_modules, src/generated) and Python ('src' row: src/generated) show bug 2: files under those directories are included while the ignored directory entries are dropped. Python's '.' and './src' rows show bug 1 (compare './src' with 'src'): no pattern applies, so .env, src/app.spec.ts and the directory entries are included too. For copy('.'), the upload tar (spoolTarArchive / tar_file_stream) contains exactly the '.' row.

Additional context

Root cause

  • packages/python-sdk/e2b/template/utils.py:156-176: wcmatch fullmatches exclude patterns against the raw result, which keeps every . and .. segment from src (./src, src/., dist/../src) or from the recursion base (normalize_path(file) + "/**/*" is ./**/* for .). .env never matches ./.env, and even **/*.spec.* doesn't match ./src/app.spec.ts. The root result . is tested as well, so .* excludes it. JS is unaffected because node-glob tests ignore against each result's normalized relative path.
  • packages/js-sdk/src/template/utils.ts:148-173 and the same Python function: .dockerignore lines (utils.ts:109-120, utils.py:105-124) go unchanged into node-glob ignore / wcmatch exclude, which test each result on its own. node-glob skips a directory's children only for patterns whose last segment is **. A leading / makes node-glob treat the pattern as absolute and match it against the full host path, and wcmatch compares it with the relative result, so a root-relative pattern like /node_modules never matches (bug 3). (In JS, a /**/ prefix does match, since it spans the host path.) Docker cleans each pattern (drops ./ and a trailing /, strips a leading /) and excludes everything under a matched path.
  • The merged patterns feed both the hash and the upload: packages/js-sdk/src/template/index.ts:1135-1138, 1226-1231 and packages/python-sdk/e2b/template/main.py:1251-1254, template_sync/main.py:134-137, template_async/main.py:134-137.

Proposed fix (happy to send a PR)

  • Normalize ignore patterns inside the collector as Docker does: skip empty lines, clean each pattern and strip a leading / as ignorefile.ReadAll does (drop ./ and a trailing /, resolve .., strip a UTF-8 BOM, which Python's read_dockerignore keeps today so a BOM file's first line never matches), and ignore a bare . pattern (Docker docs: "For historical reasons, the pattern . is ignored"). Exception: JS matches an absolute pattern (from either source) against the full host path today (<ctx>/secrets/** excludes secrets/; Python matches nothing). Stripping its / would silently start uploading those files, so first rewrite an absolute pattern that starts with the literal context path relative to it (in both SDKs), then strip a leading / from the rest. (For a .dockerignore line this differs from Docker, which reads /app/cache in context /app as app/cache; it only affects absolute paths into the context.) Then match each pattern against the path and its parent directories, never the context root, as Docker's MatchesOrParentMatches does, and don't descend into excluded directories (node-glob prunes dir/** patterns today; a post-filter would walk a huge node_modules; wcmatch's exclude never prunes, so Python needs its own walk). One change per SDK fixes both the hash and the upload tar. The CLI builds through the JS SDK, so it is covered too.
  • Python: normalize . and .. segments in src with posixpath.normpath (keeping an empty src and a trailing / as they are), so results are root-relative as in JS. The pruning walk above replaces the normalize_path(file) + "/**/*" recursion glob.
  • Tests (offline): packages/js-sdk/tests/template/utils/getAllFilesInPath.test.ts and spoolTarArchive.test.ts, mirrored in packages/python-sdk/tests/shared/template/utils/. Cover ., ./, ./src, src/., dist/../src, bare directory patterns, copying a path inside an ignored directory, a leading /, .* and . (must not drop the root or throw), an absolute fileIgnorePatterns entry under the context, node_modules not excluding src/node_modules (patterns stay root-anchored) and a hash-stability check for an ignored .git/HEAD.
  • Behavior changes (I'd call them out in the changeset):
    • Files inside ignored directories stop being uploaded, as in Docker, and the files hash changes once for affected copies. A template that relied on an ignored directory's contents (for example a host node_modules listed in .dockerignore) must drop that ignore line.
    • Copying a path inside an ignored directory by name (.next ignored, then copy('.next/standalone', ...)) works today in both SDKs and would fail with No files found, as Docker's COPY fails when the path is not in the context. Without ! support, the workaround is narrowing or dropping the ignore line, and dropping it re-includes the whole directory for copy('.').
    • Python: patterns start applying when src has . or .. segments, so copy('.') stops uploading .env and the other ignored files, and missing/../src stops failing with No files found.
    • Leading-/ patterns start excluding in both SDKs, and absolute file_ignore_patterns under the context start excluding in Python.
    • fileIgnorePatterns gets the same rules, since it is merged with the .dockerignore lines.

Question for maintainers: ! negation

Neither SDK supports ! lines today (node-glob compiles ignore with nonegate, and wcmatch runs without NEGATE). So an allowlist .dockerignore like

*
!src

is already broken: copy('src') fails with "No files found" in both SDKs, and copy('.') uploads the contents of every top-level directory (node_modules/**, .git/**, ...) in JS and everything in Python. Docker's context has only src and its contents. The directory fix alone would make copy('.') upload only an empty root. It would also drop a file re-included with !node_modules/foo/index.js, which is uploaded today with a bare node_modules line and copy('.'), and it would leave no way to copy a path inside an ignored directory (see Behavior changes). Which do you prefer?

  1. Implement Docker's rule in the same PR: the last matching pattern wins, each pattern is checked against the path and its parents, and excluded directories are still walked when a later ! pattern could match inside them (a small matcher in each SDK). fileIgnorePatterns come before the .dockerignore lines in the merged list, so under last-match-wins a .dockerignore ! line could re-include a path excluded by fileIgnorePatterns. Keeping fileIgnorePatterns as a separate always-exclude list avoids that.
  2. Ship the directory fix first and log a warning when a ! line is present. An error would break builds that work today.

Related

Activity

  1. hardness1020 commented on Sep 26, 2026

    @hardness1020
    Author

    I'd like to work on this and will open a PR with the proposed fix (both SDKs, with tests and a changeset). Input on the ! negation question above is welcome before then.

  2. devin-ai-integration commented on Sep 26, 2026

    @devin-ai-integration
    Contributor

    Triage

    • type: bug
    • priority: high
    • complexity: medium
    • affected_area: js-sdk/template utils (getAllFilesInPath) + python-sdk/template utils (get_all_files_in_path)
    • actionable: yes
    • needs_clarification: false
    • summary: .dockerignore / fileIgnorePatterns are not applied Docker-style in both SDKs' template copy() file collector (directory patterns don't exclude contents, leading / matches nothing, Python skips patterns when src has ./.. segments), causing ignored files such as .env, node_modules and .git to be uploaded into templates and to churn the files hash; the reporter has volunteered a PR and asks maintainers to decide whether ! negation support should land in the same PR or as a follow-up.

    Written by Devin

  3. added a commit that references this issue on Sep 30, 2026
    03887ad
  4. devin-ai-integration commented on Oct 1, 2026

    @devin-ai-integration
    Contributor

    @hardness1020 thanks for the detailed report and repro! The fix shipped: template copy() now applies .dockerignore / fileIgnorePatterns with Docker-style semantics (directory patterns, leading /, negations, ./.. sources) consistently for hashing and upload in both SDKs — #1917

    It's available in JS SDK e2b@2.52.0 and Python SDK e2b==2.52.0:

    npm i e2b@2.52.0
    pip install -U e2b==2.52.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsdkImprovements or additions to SDKs

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions