Skip to content

feat(chat): support .zip uploads with extract into workspace files/#5564

Open
Sg312 wants to merge 34 commits into
devfrom
feat/zip-extract
Open

feat(chat): support .zip uploads with extract into workspace files/#5564
Sg312 wants to merge 34 commits into
devfrom
feat/zip-extract

Conversation

@Sg312

@Sg312 Sg312 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds .zip support to copilot chat uploads with an explicit extract-first flow: a zip is stored once in the chat's uploads/ namespace, and the agent unpacks it with materialize_file(operation: "extract") into a files/<archive>/ folder tree, where the contents are readable with the normal files/ tooling. Reads/greps of a raw zip return extract-first guidance instead of binary bytes. The shared extraction primitives (zip-bomb / zip-slip / symlink guards, size caps) are factored out of the file-manage decompress route into lib/uploads/archive.ts and reused by both paths.

The branch also hardens the extract path from a deep review pass: the zip-bomb pre-scan now walks the real EOCD-anchored central directory (reusing file-parsers/zip-guard) so zips containing stored nested archives are no longer falsely rejected; extraction is all-or-nothing (a discarding validation pass proves the caps before anything is uploaded, so lying headers or corrupt entries can't leave partial trees); save/import refuse archives (saving stranded the contents unreachably); re-extracting into a non-empty folder is refused instead of duplicating every file as a (1).txt; degenerate names (..zip, control chars) fold into a safe fallback folder; small mislabeled .zip files are byte-sniffed so they stay readable; and zip acceptance is scoped to the mothership flow only (execution/workspace/deployed-chat upload gates keep rejecting zips up front). ArchiveError messages are single-sourced with accurate caps, and per-entry zip-slip forensics are logged again.

Type of Change

  • New feature

Testing

  • 153 unit tests across archive (incl. new regression tests: nested stored-zip false positive, corrupt-entry all-or-nothing, noise entries not counting toward the 1000-file cap, synthetic central-directory cap tests), materialize-file (save-on-zip guard, already-extracted guard, fileNames dedupe, degenerate-name fallback, cross-workspace guard), upload-file-reader (zip guidance, large-zip no-download, mislabeled-zip readable), zip-guard, validation, file-utils, payload, and the upload/presigned routes
  • tsc, biome, and check:api-validation all clean
  • Generated tool-catalog-v1.ts / tool-schemas-v1.ts regenerated from the companion's contract and byte-verified in sync
  • Reviewers should focus on: the two-pass extraction loop in lib/uploads/archive.ts, the gate scoping in lib/uploads/utils/validation.ts + upload/presigned routes, and the extract guards in lib/copilot/tools/handlers/materialize-file.ts
  • Known deferred follow-ups (called out for visibility): no extract path for zips already under files/ (compress-op output, nested zips), and the per-entry sequential upload loop / whole-archive buffering inherited from the old decompress route

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Companion PR

Companion: https://github.com/simstudioai/mothership/pull/348

Sg312 and others added 30 commits July 8, 2026 18:12
…KB tool handlers

Mirrors mothership dev f90f9b05:
- regenerated tool-catalog/tool-schemas mirrors (search trigger replaces
  research + scout; QueryUserTable / SearchKnowledgeBase entries)
- queryUserTableServerTool / searchKnowledgeBaseServerTool: read-only
  wrappers delegating to the full user_table / knowledge_base handlers with
  hard operation allowlists (and outputPath export rejection on
  query_user_table)
- display maps: 'search' agent label/title/icon added; research + scout
  entries retained so historical transcripts keep rendering
- Search.id replaces Research.id in LONG_RUNNING_TOOL_IDS (it inherits
  research's long crawls)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors mothership dev db60da94: run_code is the compute-only variant of
function_execute for the search agent — same sandbox and inputs, no
outputs.files / outputTable, so it cannot create or overwrite workspace
resources. Wrapper handler hard-rejects the write vectors and delegates to
executeFunctionExecute; run_code is deliberately absent from
OUTPUT_PATH_TOOLS and the table output post-processor, so the name gating
blocks writes even for leaked args. Added to LONG_RUNNING_TOOL_IDS,
display title/icon maps, and the regenerated catalog/schema mirrors.

Also removes two ineffective biome suppression comments in the docs
workflow-preview (the rule doesn't fire in the docs app config).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…data

A failed handler result that carried a defined-but-empty output (the
app-tool executor's 'Tool not found' ships output: {}) won the priority
race in getToolCallTerminalData, so the resume payload's data — the only
thing the model reads — was a bare {} with the error text dropped. The
search agent retried run_code 20+ times blind against a stale server
because every failure rendered as empty instead of 'Tool not found'.

Failed calls now always carry error in their terminal data: merged into
object outputs, wrapped alongside non-object outputs, preserved when the
output already has an error field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…splay

Companion to mothership 8ae32e97 (user_memory tool removed — the feature no
longer exists). Regenerates the mothership contract mirrors via
generate-mship-contracts.ts, which also picks up the pending telemetry
contract additions (gen_ai.agent.name labels, llm.client.context_tokens,
llm.client.compactions, llm.request.compaction_trigger, llm.compaction.pause,
gen_ai.usage.context_tokens), and removes the user_memory display title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…select types only

UI ordering: answering a question card no longer echoes a duplicate user
bubble. The combined answer still goes on the wire as a user message, but the
chat pairs it back to its card (strict 'Prompt — Answer' match, now uniform
for single questions too) and renders the card as the answered recap — the
card IS the user turn, and the next assistant message streams below it. The
pairing is derived from the transcript, so live and reloaded renders are
identical; a dismissed card followed by an unrelated typed message does not
match and renders normally. Messages ending with a question card also drop
the copy/thumbs actions row — the card is an input surface, not a reactable
assistant turn.

Question types are now single_select and multi_select only: text is removed
(the free-text 'Something else' row covers it) and confirm collapses into
single_select with Yes/No options. multi_select rows toggle with a check and
the free-text row's arrow submits the step; answers are comma-joined labels
plus any typed entry. Agent-supplied catch-all options ('Other', 'Something
else', 'None of the above') are stripped at parse — the card always provides
its own free-text row; a question left with no real options is invalid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes multi_select (and its toggle/check UI). The card is one shape: pick
one option or type into the always-present 'Something else' row. Catch-all
stripping and the transcript pairing/recap behavior are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-adds multi_select with a reworked interaction: option rows carry real
checkboxes (emcn Checkbox chrome) instead of numbers and arrows, an
option-styled Submit row confirms the step, and the "Something else" row
reads as a plain option until clicked — then it becomes the focused text
box, auto-checks, and can be unchecked without losing the typed text
(blur with nothing typed reverts it). single_select behavior, catch-all
stripping, and the transcript pairing/recap format are unchanged;
multi_select answers are the checked labels comma-joined.
* feat(credentials): agent-initiated oauth credential reconnect

* fix(credentials): address reconnect review findings

* improvement(credentials): log when connect draft name lookups degrade
Stop the mothership from adopting a workspace user-skill on its own:

- Remove the load_user_skill tool and its three payload callers (chat
  payload, mothership execute route, inbox executor); delete
  lib/mothership/skills.ts + its test. Skills no longer autoload as the
  agent's own instructions.
- Rename the workspace "## Skills" inventory to "## Agent Block Skills
  — NOT FOR YOU" with a one-line guardrail so a skill's description
  (e.g. "respond like a pirate") is not treated as an instruction.
  Skills reach the model as behavior only via explicit /-attach.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…top it clearing them (#5546)

* fix(copilot): persist KB tag subblocks as JSON strings from edit_workflow

The edit_workflow tool normalizes array-with-id subblocks (via
normalizeArrayWithIds) but only re-stringifies the keys listed in
JSON_STRING_SUBBLOCK_KEYS. `tagFilters` (knowledge-tag-filters) and
`documentTags` (document-tag-entry) were missing, so agent-authored tag
filters were stored as raw JSON arrays while those UI components read
their value with JSON.parse (expecting a string). The result: an agent
edit to a Knowledge block's tag filter persisted correctly but rendered
as an empty filter in the editor (JSON.parse on an array throws -> []).

- Add `tagFilters` and `documentTags` to JSON_STRING_SUBBLOCK_KEYS so
  edit_workflow stores them in the same shape the UI writes.
- Make both components' parsers tolerate an already-parsed array on read,
  self-healing values already persisted in the broken (array) shape.

Search execution was unaffected (parseTagFilters accepts arrays), so the
value was never lost — only the editor render and round-trip were broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(copilot): expose KB tag definitions in VFS meta.json

Surface each knowledge base's defined tags (displayName -> tagSlot) inline in
its meta.json via serializeKBMeta, loaded in one batched query
(loadKbTagDefinitions), so the agent can bind a knowledge-tag filter to a real
tag slot instead of guessing a tag name it cannot otherwise see.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): stringify KB tag subblocks on the nested-node edit path

The nested-node merge path normalized array-with-id subblocks but never
re-serialized the JSON_STRING_SUBBLOCK_KEYS, so editing a block nested in a
loop/parallel container still persisted tagFilters/documentTags (and
conditions/routes) as raw arrays -- the exact shape the subblock components
cannot JSON.parse.

Route all four write paths through a single normalizeSubblockValue helper so
the normalize and re-stringify steps cannot drift apart again, and extract the
duplicated string-or-array read logic into parseJsonArrayValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(copilot): tighten subblock serialization helpers

Derive KbTagDefinitionSummary from the canonical TagDefinition instead of
restating its fields, make parseJsonArrayValue generic so callers drop their
`as T[]` casts, and unexport the three builders helpers that no longer have
consumers outside the module now that normalizeSubblockValue fronts them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): stop stripping tagFilters/documentTags from the agent's workflow view

sanitizeForCopilot dropped `tagFilters` and `documentTags` from the workflow state the
agent reads (workflows/{name}/state.json), while edit_workflow is allowed to write both.
The field was therefore write-only: on a follow-up edit the agent read back an absent
field, concluded no filter was set, and cleared the user's tag filter.

The redaction was introduced for workflow *export* (#1628) and is already enforced there
by sanitizeWorkflowForSharing's key list. The duplicate in the copilot-only
sanitizeSubBlocks was redundant for export and destructive for the agent. Removes it and
pins the contract with a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): reject malformed KB tag values instead of clearing the filter

`knowledge-tag-filters` and `document-tag-entry` had no arm in the
`edit_workflow` input validator, so they fell through to the pass-through
default. Any non-array value the agent supplied -- a double-encoded JSON
string, an object, an unparseable string -- reached `normalizeSubblockValue`,
where `normalizeArrayWithIds` coerces unparseable input to `[]`. The write
path then persisted `"[]"` over the tag filter the user had configured.

`condition-input` and `router-input` already guard against exactly this and
return an actionable error to the model. Extend that arm to cover the two KB
subblock types. It keys on subblock type, so the unrelated `tagFilters`
short-input on the Algolia block is unaffected. `null`/`undefined` and empty
arrays still clear the field, so intentional clears keep working.

Also wrap `loadKbTagDefinitions` in try/catch. Tag definitions are an optional
meta.json enrichment, but the query ran inside the top-level `Promise.all`, so
a transient failure would reject the entire workspace VFS materialize and
leave the agent unable to read any file. Now it degrades to a meta.json
without tag definitions, matching the sibling materializers.

Adds regression tests for both, plus the first tests for
`parseJsonArrayValue`, the helper that keeps pre-fix raw-array rows readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(copilot): collapse duplicate JSON-array parsing in edit-workflow builders

`normalizeArrayWithIds` and `normalizeConditionRouterIds` each hand-rolled the
same "accept a raw array or the JSON string these subblocks persist" parse.
Extract `parseJsonArray`, which returns null when the value is neither, so each
caller keeps its own distinct fallback: `[]` for the former, the untouched
original value for the latter.

Behavior-preserving. An empty array is truthy, so `[]` and `"[]"` still parse
through rather than hitting either fallback.

`validation.ts` has a third copy, but `builders.ts` already imports from it, so
sharing the helper across the two would introduce an import cycle. Left as is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(copilot): specify tag name and legal operators in KB meta.json

`tagDefinitions` exposed `displayName`, but a `tagFilters` entry must carry the
key `tagName`. An entry written with `displayName` passes validation and
persists, then filters nothing -- a silent failure. Rename the field at the
serializer boundary; the DB column is untouched.

Also emit the operators legal for each tag's `fieldType`, reusing
`getOperatorsForFieldType`. `between` is valid for number and date but not for
text or boolean, and the agent has no way to infer that. An unrecognized
fieldType yields an empty list rather than throwing.

Still unspecified, and deliberately out of scope: a filter entry's value key is
`tagValue` (but `value` on documentTags), and `between` needs `valueTo`. Those
describe the subblock entry shape, not the knowledge base, so meta.json is the
wrong place for them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(copilot): pass a nullish subblock clear through instead of serializing "[]"

`validateValueForSubBlockType` accepts null as an explicit clear, but
`normalizeSubblockValue` then ran it through `normalizeArrayWithIds`, which
coerces any non-array to `[]`, and persisted the string "[]".

No data is lost either way -- "[]" and an absent field both mean "no filters".
But it left the field present when the caller asked for it to be unset, so
`sanitizeForCopilot` showed the agent an empty filter rather than an absent
one, contradicting the absent-means-unset invariant the sanitizer documents.
It also made Algolia's `if (params.tagFilters)` see a set value, since "[]" is
truthy.

An explicitly empty array still serializes to "[]" -- clearing with a value is
distinct from clearing by omission.

Reported by Cursor Bugbot on #5546.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sg312 added 2 commits July 9, 2026 20:29
- Replace the whole-buffer central-directory signature scan with an
  EOCD-anchored walk (shared with zip-guard) so zips containing STORED
  nested archives are no longer falsely rejected, and report the accurate
  cap (new 'central_dir_too_large' reason) instead of 'Maximum is 1000'
- Make extraction all-or-nothing: a discarding validation pass inflates
  every entry against the caps before anything is uploaded, so lying
  headers or corrupt entries can't leave partial trees (corrupt DEFLATE
  streams now surface as ArchiveError instead of raw zlib errors)
- Single-source ArchiveError messages; callers surface err.message and
  map only status/reason (removes the three divergent message maps)
- Guard save/import against archives (saving stranded the contents),
  extend the workspace ownership check to all three operations, dedupe
  fileNames, and refuse re-extracting into a non-empty folder instead of
  duplicating the tree with ' (1)' copies
- Harden the extraction folder name (dot segments, control chars,
  separators) and compute the destination before extracting
- Sniff small '.zip'-named uploads so mislabeled text files stay
  readable instead of dead-ending between read and extract
- Scope zip acceptance to the mothership flow only (attachment list,
  accept attribute, upload/presigned gates) so execution/workspace/chat
  surfaces keep rejecting zips up front
- Don't count skipped noise entries toward the 1000-file cap; restore
  per-entry zip-slip forensics via skippedUnsafePaths logging
- Teach materialize_file(fileNames: [...]) in the extract guidance to
  match the declared schema
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 10, 2026 6:22pm

Request Review

@Sg312 Sg312 changed the title fix(zip-uploads): harden extract path from review findings feat(chat): support .zip uploads with extract into workspace files/ Jul 10, 2026
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds zip upload support to the chat file flow. The main changes are:

  • Zip files can be uploaded through the mothership chat path.
  • Archive reads and greps now point the agent to an explicit extract step.
  • materialize_file can extract zip contents into workspace files.
  • Shared archive validation and extraction helpers are reused by the file manage route.

Confidence Score: 4/5

This is close, but the failed-extract cleanup should be fixed before merging.

  • A mid-extraction upload failure can still leave storage usage charged for hidden files.
  • The duplicate extraction guards and small mislabeled zip handling look addressed in the changed paths.

apps/sim/lib/uploads/archive.ts

Important Files Changed

Filename Overview
apps/sim/lib/uploads/archive.ts Adds the shared two-pass archive extraction path, with cleanup that still misses storage usage rollback on failed uploads.
apps/sim/lib/copilot/tools/handlers/materialize-file.ts Adds archive extraction, workspace ownership checks, repeated-name dedupe, and existing destination guards.
apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts Adds extract-first handling for real zip uploads and byte sniffing for small mislabeled zip names.
apps/sim/lib/uploads/utils/validation.ts Scopes archive acceptance to the mothership upload flow and exposes the matching file input accept string.

Reviews (3): Last reviewed commit: "fix(zip-uploads): detect prior nested-on..." | Re-trigger Greptile

Comment thread apps/sim/lib/uploads/archive.ts Outdated
Comment thread apps/sim/lib/copilot/tools/handlers/materialize-file.ts
Comment thread apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts
- Roll back already-uploaded files when an upload fails mid-extraction
  (storage/DB error, quota crossed), so callers and retries never observe
  a partial tree
- Fold reserved system folder names (.changelogs, .plans) into the
  'archive' fallback so extraction can't write into alias-backing
  namespaces or bypass the already-extracted lookup that hides them
- Align the archive byte-sniff budget with the read path's inline text
  cap (5MB), so any mislabeled '.zip' small enough to be read inline is
  sniffed and read instead of dead-ending
@Sg312

Sg312 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

Comment thread apps/sim/lib/copilot/tools/handlers/materialize-file.ts Outdated
…act guard

The already-extracted check only looked for files directly inside the
archive root folder, so a zip whose entries are all nested (src/index.ts)
left only subfolders there and a second extract slipped past the guard,
duplicating the tree with ' (1)' suffixes. Extraction roots its whole tree
at that folder, so a prior run always leaves a direct file OR a direct
subfolder — check both.
@Sg312

Sg312 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

Comment on lines +374 to +376
for (const file of extracted) {
try {
await deleteWorkspaceFile(workspaceId, file.id)

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.

P1 Rollback Leaves Quota This rollback hides files that were uploaded before a later archive entry fails, but uploadWorkspaceFile has already charged storage usage for each successful upload. deleteWorkspaceFile only sets deletedAt, so a failed extraction can leave the workspace quota consumed by files the caller can no longer see or clean up. The cleanup path needs to reverse the storage/accounting side effects too, not just hide the metadata.

@Sg312 Sg312 requested a review from a team as a code owner July 11, 2026 00:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants