Day 86: The Attach-File Tool
Thursday. One feature lands, two bugs adjacent to it get fixed in the same commit, and the afternoon goes to the kind of code review that catches the security-shaped edges before they ship. The feature is small but visible: read-write Odoo operator agents can now attach files to Odoo records. The bugs are the kind that wouldn't have been caught without doing the feature first.
odoo_attach_file
The shape of the gap. Five of the six new v0.5.4 read-write Odoo templates have a workflow where the agent receives a file from the user — a receipt image to attach to a vendor bill, a delivery note for a picking, a medical certificate for a leave request — and is expected to attach it to the right Odoo record. Until today, the agent could only describe the file's presence in plain text; the actual ir.attachment creation had to happen outside the agent. The split was awkward in two directions: the agent's confidence in I've attached the receipt didn't correspond to anything that actually happened, and the human still had to do the final step the template was meant to automate.
The new tool closes the gap. odoo_attach_file reads an uploaded file from the agent's workspace volume — the same volume the chat composer's uploads land in — base64-encodes it, and creates an ir.attachment linked to the target Odoo record. The permission gate requires both ir.attachment.create and the target model's .write permission, so an agent that can read invoices but not write them can't sneak an attachment in through the side door. The tool is included in ODOO_WRITE_TOOLS, which means every read-write operator template gets it automatically — the Bookkeeper, Warehouse Operator, HR Operator, Project Manager, Production Operator, and Approval Manager all enable it without per-template configuration.
The matching template work is in the defaultAgentsMd field of each template: each operator picks up a File attachment workflow section describing when to use the tool and what the success criteria look like. ir.attachment got added to MODEL_CATEGORIES so yesterday's odoo-sync probe walks past it on every sync; without that, the templates would have failed yesterday's drift-guard test. An io.ts wrapper around readFile sits in front of the actual filesystem call, mostly so vi.mock() can intercept it in unit tests — the same indirection pattern the audit-emit path landed last week.
The Vision Resolver Was Wrong
The second bug surfaced as part of testing the new attach-file flow. Templates with capabilities: ["vision", ...] were getting deepseek-v4-pro resolved for them — a text-only model that can't process a receipt image at all. The Bookkeeper template, the headline use-case for the attach-file work, was falling through to a model that couldn't see the file it was about to attach.
The root cause is in resolveOllamaCloud, which picks a model from a curated list based on tier (fast, balanced, reasoning) and task type. The lookup was checking taskType before capabilities, which meant a template declaring vision but defaulting to balanced tier was getting resolved to the balanced model in the curated list — which happens to be text-only. The fix reverses the order: capabilities first, taskType second. Each tier now has an explicit vision slot — fast → ministral-3:8b, balanced → qwen3-vl:235b, reasoning → gemini-3-flash-preview — so a vision-capable template lands on a vision-capable model regardless of tier.
A drift-guard test landed alongside that asserts every tier's vision slot is listed as vision:true in TOOL_CAPABLE_OLLAMA_CLOUD_MODELS. The shape of the bug — a slot pointing at a model whose capabilities don't match the slot — is now structurally caught. The matching template-validation test asserts every read-write Odoo template declares vision, because the workflow they exist for (look at a receipt, look at a delivery note, look at a medical certificate) doesn't make sense without it.
The Afternoon Review
By 13:27 the code-review feedback on the morning's PR #356 came back, and the two findings it surfaced were both the security-shaped kind. The first: filename validation. odoo_attach_file was accepting whatever filename the agent passed, then resolving it as workspace/<agentId>/<filename> for the readFile call. An agent driven by a prompt-injected user message could pass filename: "../../etc/passwd" and the plugin would happily attach the container's /etc/passwd to an Odoo record — exfiltration available to any agent with read access to the target record. The fix is a strict filename validator: no path separators, no .., no leading dots, the filename must look like a plain filename and nothing else. The validator runs ahead of any filesystem access.
The second finding is the OOM-shaped one. readFile + base64 encoding roughly triples a file's memory footprint, and an unbounded upload could OOM the plugin process well before the Odoo upload would have rejected it. The fix is a 25 MB upper bound (matching Odoo's default web.max_file_upload_size), checked via stat() before readFile(), so oversized files are rejected without ever being loaded into memory. The ENOENT handling moved to the stat() call too, because stat is now the first filesystem touch the tool makes.
The matching test suite picked up eleven new tests in this pass. Eight of them are path-traversal cases driven through it.each — variations of ../, embedded nulls, Unicode slashes, double-encoded percent escapes — each asserting the validator rejects them. One positive case asserts a plain receipt.pdf still works. Two size-cap tests assert that a file at exactly 25 MB succeeds and one at 25 MB + 1 bytes fails before any read. The eleven tests are the kind that get cited in a security questionnaire as this attack surface has been deliberately tested against.
The Smoke-Test Race
One CI fix landed late in the evening that's worth flagging because it's the third time this exact shape has shown up. The Docker smoke test was failing intermittently with config not writable errors during the first 30 seconds of container boot. The cause is that OpenClaw's startup runs a restart-then-chmod cycle on /openclaw-config — it briefly drops write permissions during a step that re-initialises the secrets bundle, then restores them. The previous smoke test was checking the permission once and failing if it caught the wrong tick. The fix polls the check across the first ~30 seconds with a backoff, so any single tick that lands during the chmod-down window doesn't fail the test.
The third time the same shape has come up is the signal that this isn't a flake — it's a deterministic race the test wasn't accounting for. The polling pattern is now the canonical shape for any check that has to ride out OpenClaw's startup choreography; future smoke checks should reach for the same helper rather than write their own one-shot variant.
Day 86
The shape of a feature that lands cleanly is that the morning is the feature and the afternoon is the code review's worth of edges that the feature surfaced. The attach-file work proper is forty lines of glue between an upload endpoint and Odoo's ir.attachment model; the security hardening that followed is the difference between forty lines that ship and forty lines that get a CVE filed against them in six weeks. The vision-resolver fix is the kind of bug that wouldn't have been seen at all without doing the feature — without an agent that needs to see a receipt, the wrong model resolution looks like a tier preference rather than a routing bug. Three patterns of doing the feature surfaces the bugs in the feature's dependencies landed today, and tomorrow's work is the next pass through the same lens.