Skip to content

feat: Add get-actor-version tool - #1450

Merged
DaveHanns merged 19 commits into
masterfrom
feat/get-actor-version
Oct 1, 2026
Merged

DaveHanns merged 19 commits into
masterfrom
feat/get-actor-version

Conversation

@DaveHanns

@DaveHanns DaveHanns commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

What

get-actor-version reads an Actor version's files: the file list with a size and a hash per file, a revision for the whole set, and the content of the files asked for. It is the first tool of a new source category, which is not enabled by default.

Why

It is the read half of the Actor source tools spec (#1452). Closes #1460. The write tools in the next PRs use its hashes and revision to refuse changes that conflict with a newer save.

How

  • Input: actor (ID or username/name), versionNumber (defaults to the only version), paths, and startLine with lineCount for one path.
  • Without paths, it returns the file list only. With paths, it returns those files in order within 256 KiB. Requested paths are normalized like stored names, so ./src/main.js finds src/main.js.
  • Files left out over the limit go to omittedPaths, and paths with no file to notFoundPaths. The text summary names both and says what to do next, for a caller that reads only the text.
  • Text comes back as utf8 and binary files as base64. A UTF-8 file stored as BASE64 (apify push stores .ts files that way) comes back as text.
  • hash is the first 16 hex characters of the SHA-256 of a file's bytes. revision hashes the sorted paths and hashes, so neither depends on how the version stores its files.
  • A version built from a Git repository or a gist is refused with its URL, with credentials hidden, and a pointer to the repository or gist. A version stored as a zip is refused without its internal URL. Nothing is downloaded.
  • One Actor GET reads the version. API errors go up unchanged, and the framework reports a 4xx as a soft failure with the API's message.

Testing

Unit tests cover each point above, the refusals, and schema conformance. type-check, lint, format, test:unit, and check:agents pass. A reviewer tested it end to end against the live API.

Notes

AI disclosure: implemented with Claude Code; awaiting human review.

🤖 Generated with Claude Code

Adds get-actor-version, the read side of the source tools that replace
push-actor and pull-actor, in a new source category that is not enabled
by default.

It returns a version's metadata, a file manifest (path, decoded size,
hash, format), a revision for the whole file set, and the content the
caller asks for within MAX_INLINE_BYTES. Without paths it returns every
text file only when all of them fit, so one large file cannot crowd out
the rest; paths: [] gives the listing only; named paths fill the limit
in order, and a single large text file can be read in line ranges.
Environment variables come back as names and isSecret only.

The hash is the first 16 hex characters of the SHA-256 of the file's
bytes, and the revision hashes the sorted path and hash lines, so both
are the same for a version stored inline and one stored as a zip.

Zip-stored versions are read only from a key-value store record on the
session's own API host, with the caller's token. The zip is parsed from
its central directory with no new dependency, because apify push writes
zeros for sizes in local headers. Archives over 50 MiB, over 10,000
entries, or over 64 MiB uncompressed are refused, as are encrypted,
zip64, symlink, and unsafe or duplicate names, and every entry's length
and CRC-32 are checked after inflating. Git, gist, and outside zip
sources return only their URL, without its query string.
Zip reading:
- Refuse an archive whose end record declares fewer entries than the
  central directory holds, and an entry whose local header names another
  file or method, so the files reported match what other zip readers see.
- Check entry names for length, NUL, absolute paths, and '..' first, so
  no refusal message repeats an overlong name.
- Skip a root folder entry such as './' instead of refusing the archive.
- Detect text with isUtf8 and decode only the files that are returned,
  so a listing holds only the inflated bytes the 64 MiB cap bounds.

Inline files:
- Read a file stored without format as TEXT and one without content as
  empty, as the build worker does, instead of failing or breaking the
  output schema.
- Normalize inline paths the way the zip reader does, so the same files
  give the same manifest and revision however they are stored.
- Return a UTF-8 file stored as BASE64 as text, as a zip read does. The
  manifest keeps the stored format.

Results:
- A line over the 256 KiB limit no longer fails the call. The file goes
  to omittedPaths and the summary names the startLine that skips it, or
  says a single-line file cannot be read by lines.
- The summary no longer tells the caller to name a base64 file too large
  to return; it points to the Apify CLI instead.
- Remove the query string from Git and gist URLs too, and http user and
  password from every URL, before returning them or hashing the revision.
- Word the outside zip summary without claiming the source is not on
  Apify.
- The description says the API hides the source of most Actors the
  caller cannot modify, and gives the macOS hash command.

Tests cover each rule and the boundaries that no test pinned before:
hidden sources for URL types, a record URL on another host, local extra
fields that differ from central ones, byte order of the manifest, base64
files next to text at the limit, and the entry count, size, CRC, and
encryption flag edges.
The Apify API cannot read or change single files of an Actor's source, so the version GET returns every file and a zip-stored version is downloaded and unpacked whole, even for one file. TODO comments mark both places for when such an API exists.
…y it

Remove the zip reader, its test helper, and its tests. A version stored as a
zip in a key-value store of this API is now refused as a user error; a zip at
an outside URL is still reported by its URL. A TODO marks where zip reading
comes back and the library planned for it.

Other simplifications, with the same output data:
- One summary function replaces the format helpers.
- A line over the limit is reported only when nothing of the range fits.
- No Apify CLI hint for files too large to return.
- Paths sort in plain JavaScript string order instead of UTF-8 byte order,
  for the manifest and the revision lines.
- One hash helper, and the manifest builder and folder check live in
  source_files.ts.
- Say that the tool does not download zips when it reports an outside
  zip URL, since a URL on the session's own API host is not outside the
  Apify API.
- Describe the path normalization correctly: empty and `.` segments are
  dropped as the build worker does, and backslashes become `/`, which
  the build worker does not do for inline names.
- Pass only the API base URL to the version reader, since it no longer
  calls the API, and fix comments that still mention the source store.
- Stop exporting the hash helper and the format type, and read an
  entry's name, format, and content in one destructuring.
- Test that a BASE64 file with a text extension and invalid UTF-8 comes
  back as base64, that a byte order mark is kept, and that the last of
  two entries with the same path wins.
Narrow the tool to what the spec asks for:
- Without paths, return the file list only. Drop pathPrefix and the
  read-all-text-files default.
- Keep basic line ranges; a range over the limit goes to omittedPaths.
  Drop the automatic range and the long-line handling.
- Refuse every version not stored as files with one message that names
  its URL without credentials. Drop Git, gist, and zip reporting, the URL
  revision, and the split of Git URLs.
- Read the version from the Actor GET, which already hides the source the
  same way. Drop the second GET, the 4xx catch, and the no-versions branch.
- Drop env var names, the stored format, the source type, and the build
  tag from the output, and keep the text result to one sentence.
- Normalize stored names with posix.normalize, as the build worker does.
Hide the password of a URL in any scheme, not only http and https, so an
ssh:// Git URL with a password no longer shows it in the refusal. The SSH
user stays.

Test that a BASE64 file with a binary extension comes back as base64 even
when its bytes are valid UTF-8.
# Conflicts:
#	tests/unit/tools.mode_contract.test.ts
@DaveHanns DaveHanns self-assigned this Sep 30, 2026
# Conflicts:
#	README.md
#	src/tools/registry.ts
@DaveHanns
DaveHanns marked this pull request as ready for review September 30, 2026 06:41
@apify-service-account apify-service-account linked an issue Sep 30, 2026 that may be closed by this pull request
5 tasks
@apify-service-account apify-service-account added tested Temporary label used only programatically for some analytics. t-builders Issues owned by the Builders team. labels Sep 30, 2026
@apify-service-account apify-service-account linked an issue Sep 30, 2026 that may be closed by this pull request
1 task
@DaveHanns
DaveHanns requested a review from MQ37 September 30, 2026 06:51
… get-actor-version

- Compare the text block with structuredContent for file contents, base64,
  CRLF text, and a line range.
- A file stored without format is text even with a binary extension.
- Binary extensions match in any case, and base64 comes back as stored.
- A path with no file uses none of the byte limit.
- The description states the spec's 256 KiB, not the shared constant.
- A version with no files is an empty listing, not a hidden source.
- A version without a number is left out of the several-versions message.
- A sub-resource with a username but no name is a missing Actor.
- buildFilesRevision sorts its input by path itself.

@MQ37 MQ37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve, with one thing to fix before merging.

I tested this branch end to end against the live Apify API. It works. File content is byte-exact, hashes and revision are correct and stable, and line ranges are right. The tool uses only the session's own token, and envVars are never returned.

Fix before merge

  • A file or range over 256 KiB is omitted with no explanation.
    • Repro: {"actor":"<actor>","versionNumber":"0.0","paths":["src/big.log"]} with a file over 256 KiB.
    • Result: isError=false, contents:[], omittedPaths:["src/big.log"], and the only text is "Read version 0.0 of …". A caller that reads the text alone can think it got the file.
    • startLine on its own, or a large lineCount, does the same.
    • Please name the omitted paths in the summary, with the limit and a hint to use startLine/lineCount.
    • The same goes for notFoundPaths. Directories like src/ and paths like ./x only show up there.

Follow-ups, not blocking

  • Validate actor as an ID or username/name. It goes into the API URL unescaped, so ../ is normalised. It's GET-only under the caller's own token and gave no leak in my probes.
  • Every read repeats the full file listing, and content[0] duplicates structuredContent.
  • Refusals give no next step, and the zip message shows TARBALL and the internal record URL.
  • Unknown parameters are silently dropped and types are coerced (src/utils/ajv.ts). That isn't this PR, so I'll open a separate issue.

Nits (optional)

  • Build lineRange once instead of isLineRange plus lineRange.
  • Make content eager instead of the lazy readContent closure.
  • Trim the adm-zip TODO and the long docblocks.
  • Merge the near-duplicate tests into it.each.
  • Replace files.find in the loop with a Map.
  • Use one template literal for the not-found message instead of +.

Build the line range once, keep each file's content instead of a function
that builds it, look files up in a Map, write the Actor-not-found message
as one template literal, and shorten the TODOs and long doc comments.
The text said only "Read version <n> of <fullName>.", so a caller reading
the text alone could take a file that did not come back as read. It now
names the files left out over the limit with a next step (fewer lines for
a line range), and the paths with no file.
Stored names are normalized, so ./src/main.js found no file although the
listing has src/main.js. Requested paths are now normalized the same way,
paths to the same file count once, and a path with no file is reported as
the caller wrote it.
A version not stored as files was refused with its source type and URL.
The refusal now says where the files are and what to use instead: the Git
repository or the gist, with credentials still hidden from the URL. A zip
version is said to be one apify push stores for sources over 3 MiB, and
its internal URL is no longer shown. Other source types are named as not
supported. The wording fits tools that read and tools that write.
The base64 detection, line range, line range refusal, and missing-Actor
tests differed only in input and expected output. Every case is kept.
@DaveHanns
DaveHanns merged commit 0c1c93e into master Oct 1, 2026
17 checks passed
@DaveHanns
DaveHanns deleted the feat/get-actor-version branch October 1, 2026 12:19

Copy link
Copy Markdown
Collaborator

Follow-up for two text defects found with mcpc on the merged tool (0.17.2): #1470. Both came in with the commits after the approvals (41285bc, 1b372e9).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-builders Issues owned by the Builders team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read an Actor version's files over MCP (get-actor-version)

5 participants