Skip to content

feat(agents): drop or open a text file into the data box - #12192

Merged
MarkusNeusinger merged 7 commits into
mainfrom
feat/agent-file-drop-text
Oct 15, 2026
Merged

MarkusNeusinger merged 7 commits into
mainfrom
feat/agent-file-drop-text

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Drop zone, .open() and pasted files on the agent chat's data box (PR 1 of 2: text files). The browser reads the file (app/src/sections/agent-chat/fileText.ts):
    • Magic bytes first. Spreadsheets (xlsx/ods/xls), Parquet, Arrow and NumPy .npy/.npz get "a later step, copy the cells or export as CSV for now". Pickle and HDF5 get "never, export as CSV or Parquet". PDF, archives and other binary files get their own answers.
    • Then the encoding. A byte order mark (UTF-8, UTF-16LE, UTF-16BE), then the NUL pattern for BOM-less UTF-16 (also when most of the text is CJK), then strict UTF-8, else windows-1252, never ISO-8859-1. A marked file that is not valid UTF-8 falls back to windows-1252 rather than writing � into a cell.
    • Binary check. A NUL or more than 1 % control characters reads as binary.
    • Caps. The file is read once: 4 MB raw cap before the read, 1 MB UTF-8 cap after decoding. CRLF and CR become \n once, so the box, its counter, the line count and the payload agree.
    • Filling the box. The text goes into the box unchanged and .parse() takes the focus. An aria-live line says what was read. If several files are dropped at once, the first is read and the line says "first of N files read — one file at a time".
    • Box with text. The panel asks first with .replace() or .keep(). .keep() returns the focus to the box. An edit or a text paste drops a waiting question. .replace() waits while a step runs.
    • Guards. A running step refuses the file. A window-level guard stops the browser from opening a file dropped next to the box.
    • Paste. A pasted file is read like a dropped one; pasted text wins over Excel's picture of the cells.
    • Picker. accept lists MIME types and extensions.
  • The name is metadata, never a note (review fix, verified). It travels as the structured source field: the BFF DatasetBody allowlist and the agents DatasetBody accept 1 to 1,024 characters.
    • Sanitising. opening.source_name applies NFC, turns controls into spaces, strips bidi and zero-width characters, strips a path, and cuts to at most 80 characters with a middle … that keeps the extension.
    • Screening (main._screen_file_name). The pre-filter runs on the name alone, then always its own scope-judge call (owner decision of 2026-10-15). A hit, a refusal or an outage keeps (name withheld). That costs no strike, leaves the notes and the constant columns alone, and the judge's lang never sets the session language.
    • Storage. The name is stored as StoredDataset.file_name and carried to every version.
    • What the root sees. The root reads it fenced as <user_data> under a "file name, never a request" preamble (policy.file_name_block). It appears in the summary turn and in get_dataset_profile under file_name. The session block gets a fixed line only, and root.md gets one bullet.
    • Elsewhere. The adapter and the reviewer get a kept name in the dataset context. The data panel shows it next to the row count.
    • Privacy. The name is never logged (file_name_judge attribution carries verdict and length only) and never sent to Plausible.
  • Analytics. agent_data_file{kind, encoding, outcome} comes from DataPanel via the page's trackEvent and is documented in docs/reference/plausible.md. The design doc, docs/reference/api.md and the dev BFF mock are updated.

Plan

Owner decisions and the Fable second opinion are in Todoist task 6hmC9cvPH3Hhc7rf. The review round (Fable + Gemini) and the owner's decisions of 2026-10-15 are applied:

  • The name is always screened.
  • The name sticks through typing; a text paste, an emptied box or .send() drops it. A table read from a file keeps the name for .use() in the session state.
  • Several files: the first is read, and the line says so.
  • NumPy moves to the "later step" group (PR 2).

Spreadsheets and Parquet are PR 2. root.md also changes in #12191; this PR adds one bullet after the notes bullet (line 57), far from #12191's hunks.

Owner action: Plausible registration (anyplot.ai site)

  • Register the custom properties kind, encoding and outcome. kind also exists on the bots site, which is harmless because properties are registered per site.
  • Add the goal agent_data_file (Custom event).

Test plan

  • uv run ruff check . && uv run ruff format --check ., mypy api core agents clean
  • uv run pytest tests/unit -n auto: green except one pre-existing xdist flake in tests/unit/workflows/test_pipeline_retry_gaps.py, which passes serially and which this PR does not touch
  • Backend tests:
    • test_source_name.py: sanitising, controls to spaces, judge input, the file_name_block fence and preamble, the dataset context.
    • Service flow: a JSON file stays JSON; the name is judged on its own call and never logged; a name that reads like a plot wish reaches the root only as <user_data>, never in <user_notes>, also via get_dataset_profile and the session block; prefilter, attack, out_of_scope and outage keep (name withheld) with the notes intact and no strike; the name's lang never sets the session language; an over-long source is a 422.
    • test_dataset_versions: the name is carried to every version.
    • BFF body allowlist.
  • cd app && yarn lint && yarn fm:check && yarn type-check && yarn test && yarn build on Node 24 (1093 tests).
    • fileText.test.ts: UTF-8, UTF-8 BOM, the BOM fallback to windows-1252, UTF-16LE/BE with and without BOM, CJK-heavy BOM-less UTF-16, cut UTF-16, windows-1252 with €/„/“/’, magic bytes including .npz, CRLF, the caps, a single read.
    • DataPanel: fill, focus and source; replace/keep with focus; edit or paste drops the question; first of several files; CRLF; the file-name line, kept and withheld; .replace() held while a parse runs; busy and binary refusals; .open(); pasted file and pasted text; a new file discards the old table; a superseded slow read; hover and the stray-drop guard.
    • Hook: the source is kept on the transcription for .use().
  • /verify-frontend against the BFF mock (ports 3017/8017), at 1440 and 390, light and dark:
    • A windows-1252 CSV, a CRLF file and a two-file drop.
    • The replace prompt, the xlsx refusal, the busy refusal.
    • The file-name line after .parse() (13 px, no horizontal overflow).
    • The console shows only the known font-CORS noise off port 3000.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M

The agent chat's data box takes a text file by drag and drop, a visible
.open() file button (keyboard, phone) or a pasted file. The browser sniffs
binary formats by magic bytes (spreadsheet/Parquet: a later step; pickle and
HDF5: never), detects the encoding (BOM UTF-8/UTF-16LE/UTF-16BE, NUL pattern,
strict UTF-8, else windows-1252), refuses binary-looking text and checks the
raw 4 MB and decoded 1 MB caps, then fills the box; the user presses .parse().
A box with text asks before it is replaced, a running step refuses the file,
and the browser never navigates to a stray drop.

The file name travels as the dataset route's structured `source` field (BFF
and agents), never inside the text. The agents service sanitises it, runs the
notes pre-filter on it alone (a hit is withheld) and appends
`source file: <name>` to context_lines after the parse, so the existing notes
judge reads it in the same call. Never logged, never sent to Plausible;
analytics get agent_data_file{kind, encoding, outcome}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
Copilot AI balanced review requested due to automatic review settings October 15, 2026 08:23
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.79832% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
app/src/sections/agent-chat/DataPanel.tsx 93.37% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI 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.

🟡 Changes recommended

Imports can silently corrupt malformed UTF-8 or retain stale transcriptions and superseded file-read results.

3 open findings
What changed in this PR

Adds text-file import to the agent chat through the existing dataset parsing flow.

Changes:

  • Supports dropped, chosen, and pasted files with encoding detection and format refusals.
  • Passes filenames separately through sanitization and notes screening.
  • Adds analytics, tests, and documentation.
File Description
tests/​unit/​api/​test_agent_router.py Tests source forwarding and validation.
tests/​unit/​agents/​runtime/​test_source_name.py Tests filename sanitization and limits.
tests/​unit/​agents/​runtime/​test_service_flow.py Tests filename screening and storage.
docs/​reference/​plausible.md Documents file-import analytics.
docs/​reference/​api.md Documents the optional source field.
docs/​concepts/​agent-network.md Explains file ingestion and screening.
changelog.d/​agent-file-drop-text.md Records the feature.
app/​src/​sections/​agent-chat/​messages.ts Adds file-refusal messages.
app/​src/​sections/​agent-chat/​fileText.ts Detects formats and decodes text.
app/​src/​sections/​agent-chat/​fileText.test.ts Tests decoding and import limits.
app/​src/​sections/​agent-chat/​DataPanel.tsx Adds file selection and replacement controls.
app/​src/​sections/​agent-chat/​DataPanel.test.tsx Tests file-import interactions.
app/​src/​pages/​AgentChatPage.tsx Connects source forwarding and analytics.
app/​src/​lib/​agent.ts Sends optional dataset source metadata.
app/​src/​hooks/​useAgentSession.ts Forwards source through parsing and confirmation.
app/​scripts/​agent-bff-mock.mjs Returns filename notes in mock responses.
api/​routers/​agent.py Validates and forwards source metadata.
agents/​main.py Adds filenames to parsed notes.
agents/​anyplot/​prompts/​scope_judge.md Recognizes ordinary filename notes.
agents/​anyplot/​opening.py Sanitizes and pre-filters filenames.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/src/sections/agent-chat/DataPanel.tsx
Comment thread app/src/sections/agent-chat/DataPanel.tsx
Comment thread app/src/sections/agent-chat/fileText.ts Outdated
A file accepted into the box discards a pending transcription of the earlier
text (and keeps the card open), so `.use()` never stores the old table under
the new file's name; a read superseded by a later file is dropped through a
read-generation counter; UTF-8 behind a byte order mark decodes strictly, so a
broken sequence is refused instead of becoming U+FFFD in a cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M

Copilot AI 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.

🟡 Changes recommended

Replacement during an outstanding parse can associate an earlier transcription with the new file’s name.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced

Comment thread app/src/sections/agent-chat/DataPanel.tsx Outdated
MarkusNeusinger and others added 4 commits October 15, 2026 10:37
A parse of the box's text started after a file was held for `.replace()`
could answer with a transcription after the swap, which `.use()` would then
store under the new file's name. `.replace()` now waits while the session is
busy, like `.open()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
Review of #12192 (Fable + Gemini, verified): the file name landed in
context_lines, which the root reads inside <user_notes> as the user's own
requests and acts on at once. The scope judge passes plot wishes, so a file
named "q1 - drop rows where ... and set title to X.csv" would have been
executed although the user never wrote it to the assistant.

The name is now separate metadata. The dataset route sanitises it (controls
become spaces), screens it on its own (the pre-filter, then always one
scope-judge call, owner decision 2026-10-15) and keeps it as
StoredDataset.file_name, carried to every version. A refusal or an outage
keeps "(name withheld)" without a strike and leaves the notes and the
constant columns alone; the judge's lang never sets the session language.
The root reads the name fenced as <user_data> under a preamble that says it
is a name and never a request (summary turn, get_dataset_profile, a fixed
session-block line, one root.md bullet); the adapter and the reviewer get a
kept name in the dataset context; the data panel shows it.

Page: CRLF normalised once in readBytes; BOM-less UTF-16 detected for
CJK-heavy text; a file read once; a BOM-marked file that is no valid UTF-8
falls back to windows-1252; `.keep()` returns the focus to the box; an edit or
a text paste drops a pending `.replace()` and the old notice; the table read
from a file keeps the name for `.use()` in the session state; several files:
the first is read and the line says so; NumPy .npy/.npz move to the
"later step" group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
…ence for the models

Re-check of #12192 (Gemini, verified; Fable):
- get_dataset_profile's `file_name` key is allowed by ToolSafety, and the
  tool's result cap reserves MAX_FILE_NAME_RESULT_BYTES for it; the profile
  budget shrinks by the same amount. A test runs the widest profile, notes and
  name through ToolSafetyPlugin.after_tool_callback.
- A computed table (generate_data, run_data_code: they always pass a source)
  no longer carries the dropped file's name; prepare_data, .to_long() and an
  undo keep it.
- The adapter and the reviewer get the name as AdaptRequest/ReviewRequest
  `file_name`, under its own "File name" heading fenced as <user_data>, never
  inside the dataset context's <user_notes> block whose titles and units they
  take; a withheld name is not sent. One clause each in adapter.md, reviewer.md
  and reviewer_catalogue.md.
- Stale comments about the old "added to the notes" behaviour fixed.
- Tests: the name only in its labelled fence in all three requests, fullwidth
  and doubled fence tags, a withheld name reaching the root as withheld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
@MarkusNeusinger
MarkusNeusinger merged commit 5f82296 into main Oct 15, 2026
15 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the feat/agent-file-drop-text branch October 15, 2026 09:59
MarkusNeusinger added a commit that referenced this pull request Oct 15, 2026
…mPy files (#12194)

## Summary

Drag and drop, PR 2 of 2 (binary data files) for the admin agent chat.
PR 1 (#12192) read text files in the browser; this PR reads Excel
(`.xlsx`, `.xlsm`, `.xlsb`, `.xls`), OpenDocument (`.ods`), Parquet,
Feather (v1 and v2), Arrow IPC (file and stream) and NumPy (`.npy`,
`.npz`) files **on the server, in the renderer's sandbox**, and puts the
text into the paste box. The user's `.parse()` then sends it through the
ordinary paste route, unchanged (`data/parse.py` is untouched), with the
file name and sheet as `source` (`Bericht.xlsx · sheet "2024"`),
screened as in PR 1.

- **Converter** (`agents/anyplot/render/convert.py`, standalone like the
harness, copied to `/opt/anyplot/convert.py`). Format from the first
bytes only, never the name.
- Every zip is checked from its central directory first: at most 2,000
entries, 100 MB declared uncompressed, 250 times the archive's size, no
encrypted member.
- An OLE container with `EncryptedPackage` is refused with "remove the
password". Pickle, joblib and HDF5 are never read.
- Workbooks go through **python-calamine** (MIT): stored values only, so
no formula, external link, macro or DDE runs. The sheet goes out as its
raw cell grid via `csv.writer`: ISO dates, shortest numbers,
`TRUE`/`FALSE`, empty for nothing. So the parser's `table_block` finds a
header below row 1 and keeps a title as a note (tested end to end with
`parse_dataset`).
- Merged cells keep their value in the top-left cell only. A formula
without a saved value is left empty and counted (xlsx and xlsm).
- Sheets are read row by row and refused past 20,200 raw rows or 50
columns, whatever dimension the file declares. Hidden sheets are skipped
and counted; very hidden ones are never named.
- Parquet, Feather and Arrow go through **pyarrow** (26.0.0, at least
14.0.1 for CVE-2023-47248). The footer row and column counts are checked
before any page is read, at most 20,001 rows are read, and every read is
single-threaded.
- Structs become dotted columns, dictionaries are decoded, and
timestamps with a time zone are written in UTC. Map, binary and union
columns are dropped and counted.
- **List columns follow #12191's list rule.** A table with a list column
goes out as **JSON records**, so the parser's own expansion does the
work (one row per value, `record_id`, thinning). There is no second
implementation.
- NumPy loads with `allow_pickle=False` after the header's shape is
checked against the bytes present. Only 2-D or structured arrays are
read; each `.npz` member is a "sheet", and a pickled member is skipped
or refused.
- Caps: 1 MiB of text (the paste cap), 20,000 rows, 50 columns. A file
over a cap is refused with its numbers, never cut.
- **Harness convert mode** (`harness.py --convert input.bin`). As in
data mode it reads the bytes, loads the converter, and preloads the
readers (including a cp437 and deflate warm-up for `zipfile`) **before**
it arms `ConvertGuard`. `ConvertGuard` is `DataGuard` with no `exec` at
all. It allows one `compile`: NumPy's `.npy` header reader parsing its
dict literal to an AST, matched by its exact caller chain. The harness
writes `out.csv`, `meta.json`, and `status.json` with the refusal's exit
code (10 + `REFUSALS` index), the measured value and the cap.
- **Renderer** `POST /convert` (`wire.ConvertRequest`: base64, sheet, at
most 15 s). It uses the same slot, memory floor, watchdog, kill path and
idempotency key (`job_id`, `convert`). `executor._prepare` writes bytes.
The sandbox environment adds `ARROW_DEFAULT_MEMORY_POOL=system`. Nothing
of the file is logged.
- **Agents** `POST /v1/sessions/{sid}/dataset/convert`:
- Backends: `ConvertJob` and `ConvertOutput` in remote, local (mounts
the converter), fake and sandbox (unsupported).
- `SerialRenderer.convert` takes the **toggle lane**, behind every
pipeline render. It runs with a 10 s timeout and a 20 s slot wait, then
`503 capacity`.
- Limits: its own counter (5 a minute, 30 a day,
`AGENT_USER_CONVERSIONS_PER_*`), checked after the daily budget and
strikes. One conversion per user at a time (`Runtime.converting`).
- Refusals answer `422 file_<reason>` with `measured` and `limit`. A run
the host stopped answers `file_timeout` or `file_too_complex`. A
renderer without the route answers `503 convert_unavailable`.
- The `data_convert` attribution line carries kind, outcome, byte count
and time only.
- **BFF** `POST /debug/agent/sessions/{sid}/dataset/convert`. The body
is taken by hand, so the new router dependency `require_body_within_cap`
refuses an oversized body by its `Content-Length` **before it is read**
(`413`); a chunked conversion body gets `411`. The BFF caps the file at
5 MB raw and passes only whole-number `measured` and `limit` through.
- **Page.** `fileText.ts` hands the four convertible kinds to the
server, up to 5 MB, and its `accept` list gains the extensions and MIME
types. In `DataPanel`, a conversion fills the box like a text file.
While a file is read, `.open()`, `.parse()` and the drop zone wait. The
line names the sheet, the shape and the warning counts, and a refusal
names its numbers. A **sheet picker** reads another sheet again: over
the file's own untouched text it replaces at once, over an edited box it
asks `.replace()`.
- **Analytics.** `agent_data_file` gains the server kinds (`xlsx`,
`ods`, `parquet`, `feather`, `arrow`, `npy`, `npz`, ...) and the
outcomes `sheet` and `failed` (`docs/reference/plausible.md`).
- **Docs.** Design doc (request flow, renderer, guardrail row, `/v1`
table), `api.md` (route and error section), `agents/README.md` settings
and the changelog fragment. The dev BFF mock answers the conversion.

## Decided and documented (within the brief)

- **Conversion and paste count separately.** The conversion counts on
its own window and never as a paste. The paste that follows counts as
one paste (`settings.user_conversions_per_minute` docstring).
- **Lane.** The conversion takes the toggle lane, so a waiting pipeline
render always goes first.
- **Auto-pick.** Without a chosen sheet, `.npz` skips pickled members
(refused only when nothing else is readable or the user picks one). A
workbook reads the first visible sheet with a cell.
- **Sheet list.** Only visible sheets with cells are offered (owner
decision 2026-10-15); the first 50 are checked, the rest counted.

## Security review and owner decisions (fe3276a)

Security review (Fable with 60 crafted files through the real harness,
Gemini by reading), all fixed with a test each:
- **Quadratic regex.** The xlsx scan now uses one possessive tag
tokenizer, which is linear. The review's 30 MB run of `<c ` previously
ran to the 10 s kill; the scan now takes 0.39 s, and the crafted
workbook converts in under 1 s (both asserted).
- **Parquet row count.** The pre-check adds up the row groups, and a
footer total that disagrees is `file_corrupt`.
- **Declared zip sizes.** Every zip member is inflated (zlib, bounded by
its declared size + 1, at most 100 MB in all). A member whose size
differs from what it declares is `corrupt`.
- **Bounding box.** One linear pass measures an xlsx sheet's cell span
before calamine loads it; past the caps it is refused with the count. A
reader that dies without `status.json` (SIGABRT) is now
`file_too_complex`.
- **Memory.** `MemoryError` and `ArrowMemoryError` now map to
`too_complex` instead of `corrupt`.
- **Small fixes:**
- npz and sheet names with controls, bidi characters or paths are never
offered;
  - a broken `.npy` header (`TokenError`) is `corrupt`;
  - durations and intervals are dropped and named;
  - a busy renderer (`503 busy` or `low_memory`) answers `capacity`;
  - the jemalloc docstring is corrected;
  - `ConvertGuard` keeps none of the `CODE_BUILDERS`.
- **Found by the new in-image smoke:** the UTF-16 codec of the OLE
sniffer loads lazily, and the guard refused it, so encrypted workbooks
came back `blocked`. It is now preloaded and tested under the real
guard.

Owner decisions of 2026-10-15:
- **pyarrow is slimmed** in the renderer Dockerfile. Removed: headers,
Cython sources, tests, Flight, S3, Azure/GCS/HDFS, ORC, the Substrait
Python module, Parquet encryption, the C++ test module and two duplicate
`libarrow_python` copies. `libarrow_substrait` stays because `lib` links
it. CI now converts every format inside the built image
(`tests/image/convert_smoke.py`, new step "Convert every data format
through the harness").
- **A formula-only sheet is refused** as `file_formulas_only`: "contains
only formulas without saved values; open it in Excel and save it once".
The formulas are counted even when the grid is empty.
- **Empty sheets are hidden** from the picker.
- **Precision is exact**, per the owner rule:
- timestamps keep every digit (nanoseconds) and their offset in their
own zone; UTC with a warning only where the zone cannot be read;
  - decimals and int64/uint64 are exact;
  - float32 is written in its shortest text;
  - dropped columns are named on the page (`dropped`).
- **The parser** (`parse._as_iso_stamps`) reads ISO timestamps with 1–9
fraction digits and `Z`/`±HH`/`±HHMM`/`±HH:MM` offsets as datetimes.
Each cell is padded to the column's longest fraction, which keeps every
value and lets pandas' `read_csv(parse_dates=…)` load the column as
`datetime64[ns, UTC+01:00]`. A column with several offsets is written in
UTC with a note. Cells with and without an offset in one column are
text. `prepare_data` writes fractions and offsets back (`isoformat()`).
The profile's min/max are the exact cells.
- Conversion limits and the sheet picker wording stay.

## Measurements

- **Renderer image size, from the CI image job:**
  - with full pyarrow: 1,012,827,949 bytes;
  - slimmed: **944,079,490 bytes (−68.7 MB)**;
  - pyarrow itself: 167 MB → 99 MB.
- No main run in the last 60 built the renderer image, so I have no
without-pyarrow baseline; about 848 MB if pyarrow and calamine are
subtracted.
- The crafted inputs: the 30 MB tag run scans in 0.39 s locally, the
zip-inflation check is linear, and a cell at A1048576 or XFD1 is refused
before loading.
- No binary fixture is committed. Workbooks, ODS files, zip bombs, OLE
stubs, Parquet, Feather, Arrow and NumPy files are all built in memory
(`tests/unit/agents/runtime/convert_fixtures.py`). New dependencies:
python-calamine MIT, pyarrow Apache-2.0 (with its NOTICE in the wheel).

## Test plan

- [x] `ruff check`, `ruff format --check`, `mypy api core agents` clean;
`uv lock --check` passes (pyarrow is added to the mypy
`ignore_missing_imports` list because it ships no types).
- [x] `pytest tests/unit -n auto`: 11,282 passed. The one failure is the
known xdist flake `test_pipeline_retry_gaps.py`, which passes serially.
  - `test_convert.py` (49 tests), covering the brief's fuzz list:
- zip bomb, entity expansion (never expanded), spoofed `XFD1048576`
dimension
- formulas without cache, encrypted OOXML, `.xls` container sniff, ODS,
multi-sheet with hidden and very hidden sheets, merged cells
- header not in row 1 through `parse_dataset`, row, column and byte caps
- hostile and garbage Parquet footers and Arrow streams, the footer row
cap
- Feather v2, Arrow stream, list columns through `parse_dataset` (6
rows)
- `.npy` 2-D, structured, 1-D refused, object array refused, spoofed
header shape, `.npz` with a pickled member
- pins across converter, harness and wire; real-harness runs per format
under the guard
- Renderer: executor convert (argv, environment, real harness behind the
fake launcher, SIGPIPE launcher, meta validation), service `/convert`
(contract, invalid jobs not echoed, shared slot, replay and conflict,
log without content), wire bounds, remote backend (round trip, end to
end with the real harness, 404, busy).
- Agents route: answer shape, nothing stored, refusals with numbers,
timeout, memory and error mapping, `convert_unavailable`, bad bodies,
own rate window that leaves the pastes alone, one conversion per user,
busy slot, toggle lane behind a waiting render.
- BFF: forwarding, body allowlist, 5 MB cap, `Content-Length` cap before
reading, `411` for a chunked body, measured and limit filtering, CSRF
header.
- [x] `cd app && yarn lint && yarn fm:check && yarn type-check && yarn
test && yarn build` on Node 24 (1,108 tests): `fileText` convert
hand-off and caps, `convertAnswer`, `toBase64`, `toAgentApiError`
counts. DataPanel tests cover base64 hand-off and source, warnings line,
sheet picker re-read, ask-before-replace after an edit, refusal with
counts, waiting while a file is read, pickle never sent.
- [x] `/verify-frontend` against the BFF mock (3017/8017), checked at
1440 light and at 390 dark:
- an `.xlsx` through `.open()` fills the box with the sheet, the
warnings line and the picker;
  - choosing sheet "2025" reads it again;
- a row-cap refusal shows its counts, wraps at 390 px, and the picker
label is 13 px;
  - no horizontal overflow;
- the console shows only the known font-CORS noise off port 3000 and the
expected 422.
- [ ] Not verifiable locally: the real Cloud Run sandbox, and the image
build and size (no Docker daemon). `.xls` and `.xlsb` parsing has no
generated fixture (no writer exists); only the sniff and refusal path is
tested.

## Follow-ups (not in this PR)

1. **Arrow/Feather row count before decoding** (Copilot round 2, thread
left open; the review's item 5). IPC and Feather batches, and Feather
v1's `read_table`, decode before rows are counted. A decompression bomb
is bounded by the sandbox and now answers `file_too_complex` instead of
`file_corrupt`, but not with the row-cap message. Fix: a small
flatbuffer reader of the RecordBatch metadata.
2. **Bounding-box pre-scan for ODS, XLS and XLSB.** The linear span scan
covers xlsx/xlsm only. ODS `number-rows/columns-repeated` and the binary
formats still rely on the sandbox limits and the new `file_too_complex`
mapping.
3. **Mixed offsets in one column** are written in UTC with a note,
because pandas reads one offset per column. Keeping each offset would
need an object column in the plot loader.

There are no new Plausible registrations: the new `kind` and `outcome`
values use properties that already exist.

## Review rounds

- Round 1 (5 comments), all applied in 4411617:
  - a conversion without `Content-Length` is `411`;
  - the rows of a table block keep their empty trailing fields;
  - sheet names travel whole;
  - repeated flattened names are renamed and counted;
  - UTC times are written in a form the parser reads.
- Round 2 (3 comments): two applied in 9a7c44d (`ConvertGuard` keeps
none of the `CODE_BUILDERS`; `meta.json` is written unescaped). One is
left open as follow-up 1.
- Security review: all applied in fe3276a (see above); per the
coordinator, there is no new Copilot round.

## Test plan update (fe3276a)

- [x] ruff, format and mypy are clean; `pytest tests/unit -n auto` shows
11,324 passed, with only the known xdist flake. One CI run of the test
job died at 79% with no failing test in its log; the rerun is green.
- [x] New tests:
  - `TestSecurityReview` and `TestExactValues` in `test_convert.py`;
- harness runs under the guard for zone-aware timestamps and an
encrypted workbook;
  - `TestPreciseTimestamps` in `test_parse.py`;
  - fractions and offsets through `prepare_data`;
- the route's mapping of a dead reader to `file_too_complex` and of
`file_formulas_only`;
  - the remote backend's busy → `RenderBusy`;
  - the frontend's dropped-columns line.
- [x] The CI renderer image converts all 14 cases inside the slimmed
image.
- [x] Frontend: 1,109 tests pass, and lint, format check, type-check and
build pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants