Compare commits

..
Author SHA1 Message Date
Nicholai 2992bf6d36 fix(tools): publish blank-body files atomically 2026-10-01 20:18:33 -06:00
Nicholai 3a125dcce5 fix(tools): close empty-write races and preserve clears 2026-10-01 20:18:33 -06:00
Aashish 9056bac95b fix(tools): refuse an empty write_file body that would truncate a file
The handler opened the target in "w" mode without looking at the body, so a
call whose content section was lost by a parser cut the file to 0 bytes and
still answered exit_code=0 (#6414). Gate the truncating open on the size the
file has on disk — the read just above it answers "" for bytes it cannot
decode, so an undecodable target would otherwise look empty — and let only an
inline-JSON content key that is literally an empty string declare the clear.

Also stop turning a null content into the four characters "None", which could
be neither refused as a lost body nor honoured as an empty write.
2026-10-01 20:18:33 -06:00
Aashish 6749d6cd81 fix(memory): reject blank memory_id on edit and delete (#6342)
In src/ai_interaction.py, when parsing text-format edit or delete actions, an empty line 2 caused memory_id to resolve to empty string. Because startswith("") is always True, the first stored memory was inadvertently edited or deleted.

This change validates that memory_id is non-empty before searching the memory list, restoring parity with the MCP memory tool.

Fixes #6342.
2026-10-01 16:27:42 -06:00
13 changed files with 590 additions and 8 deletions
+15 -4
View File
@@ -1,8 +1,10 @@
name: ci / docker publish
# Build the Odysseus image and publish to GHCR.
# push to main -> :latest, :X.Y.Z (curated release; main is fast-forwarded at releases)
# push to dev -> :dev, :X.Y.Z-dev.<sha> (rolling dev + an immutable, traceable pin)
# push to main -> :latest, :X.Y.Z, :X.Y.Z-<sha> (curated release; main is fast-forwarded at releases;
# :X.Y.Z-<sha> is an immutable, traceable prod pin — APP_VERSION may
# not move between builds, so the bare :X.Y.Z tag alone is mutable)
# push to dev -> :dev, :X.Y.Z-dev.<sha> (rolling dev + an immutable, traceable pin)
# Multi-arch (linux/amd64 + linux/arm64): each arch builds on its own native
# runner and pushes by digest, then a merge job stitches the digests into one
# manifest list and applies the tags (faster + cleaner than QEMU emulation).
@@ -120,6 +122,7 @@ jobs:
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=${{ steps.ver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=${{ steps.ver.outputs.version }}-${{ steps.ver.outputs.short }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=raw,value=${{ steps.ver.outputs.version }}-dev.${{ steps.ver.outputs.short }},enable=${{ github.ref == 'refs/heads/dev' }}
- name: Create manifest list + push tags
@@ -135,8 +138,16 @@ jobs:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
- name: Inspect
run: |
if [ "$GITHUB_REF" = "refs/heads/main" ]; then ref=latest; else ref=dev; fi
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${ref}"
# main: verify both the mutable :latest and the immutable :X.Y.Z-<sha> prod pin
# actually resolved in the registry; dev: verify :dev.
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
refs=("latest" "${{ steps.ver.outputs.version }}-${{ steps.ver.outputs.short }}")
else
refs=("dev")
fi
for ref in "${refs[@]}"; do
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${ref}"
done
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
+8
View File
@@ -36,6 +36,14 @@ docker compose up -d --build
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
The compose files pull the official multi-arch image `ghcr.io/odysseus-dev/odysseus` (published by CI on every push to `main` and `dev`) and only build locally if the pull fails — so this also works on hosts without a build toolchain, e.g. as a [Portainer](https://www.portainer.io/) stack.
**Production deployments:** pin the immutable tag instead of `:latest`. `:latest` and bare `:X.Y.Z` tags move on every push to `main`, but `:X.Y.Z-<sha>` (e.g. `1.0.2-7c8070f`) always refers to one specific build:
```bash
ODYSSEUS_IMAGE=ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f docker compose up -d
```
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](website/setup.md).
## Features
+9
View File
@@ -12,6 +12,15 @@
# host's numeric render group id when needed. See docker/gpu.amd.yml for details.
services:
odysseus:
# Official multi-arch GHCR image (linux/amd64 + linux/arm64), published by
# the "ci / docker publish" workflow on every push to main and dev.
# Docker pulls this image when it is reachable, and only falls back to the
# local build below when the pull fails (e.g. no network on the host), so
# hosts without a build toolchain (Portainer stacks, etc.) get the
# registry build. For production, pin an immutable tag via ODYSSEUS_IMAGE
# - e.g. ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f (X.Y.Z-<sha>) - since
# :latest and bare :X.Y.Z tags move on every main push.
image: ${ODYSSEUS_IMAGE:-ghcr.io/odysseus-dev/odysseus:latest}
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
+9
View File
@@ -11,6 +11,15 @@
# for setup details.
services:
odysseus:
# Official multi-arch GHCR image (linux/amd64 + linux/arm64), published by
# the "ci / docker publish" workflow on every push to main and dev.
# Docker pulls this image when it is reachable, and only falls back to the
# local build below when the pull fails (e.g. no network on the host), so
# hosts without a build toolchain (Portainer stacks, etc.) get the
# registry build. For production, pin an immutable tag via ODYSSEUS_IMAGE
# - e.g. ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f (X.Y.Z-<sha>) - since
# :latest and bare :X.Y.Z tags move on every main push.
image: ${ODYSSEUS_IMAGE:-ghcr.io/odysseus-dev/odysseus:latest}
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
+9
View File
@@ -1,5 +1,14 @@
services:
odysseus:
# Official multi-arch GHCR image (linux/amd64 + linux/arm64), published by
# the "ci / docker publish" workflow on every push to main and dev.
# Docker pulls this image when it is reachable, and only falls back to the
# local build below when the pull fails (e.g. no network on the host), so
# hosts without a build toolchain (Portainer stacks, etc.) get the
# registry build. For production, pin an immutable tag via ODYSSEUS_IMAGE
# — e.g. ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f (X.Y.Z-<sha>) — since
# :latest and bare :X.Y.Z tags move on every main push.
image: ${ODYSSEUS_IMAGE:-ghcr.io/odysseus-dev/odysseus:latest}
build: .
ports:
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
+3 -1
View File
@@ -609,7 +609,9 @@ Read a file and return its contents.""",
<file path>
<file contents>
```
Write content to a file. First line is the path, rest is the content.""",
Write content to a file. First line is the path, rest is the content. An empty body is
refused when the target already holds data — to clear a file on purpose, send
`{"path": "<file path>", "content": ""}` instead.""",
"edit_file": """\
```edit_file
+97 -1
View File
@@ -3,6 +3,7 @@ import json
import os
import re
import difflib
import secrets
import shutil
import time
from typing import Optional, Dict, Any, Tuple, List
@@ -289,12 +290,56 @@ class ReadFileTool:
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
return {"output": data, "exit_code": 0}
def _write_new_file_without_overwrite(path: str, body: str) -> None:
"""Publish a new file without exposing a writable placeholder at its path.
Stage beside the destination, then hard-link it into place. The link is
atomic and fails if another writer created the destination first.
"""
directory = os.path.dirname(path) or "."
temporary_path = os.path.join(
directory, f".odysseus-write-{secrets.token_hex(16)}.tmp"
)
fd = os.open(
temporary_path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o666,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
fd = None
temporary_file.write(body)
os.link(temporary_path, path)
finally:
if fd is not None:
os.close(fd)
try:
os.unlink(temporary_path)
except FileNotFoundError:
pass
class _EmptyBodyWouldTruncate(Exception):
"""Raised inside the write thread when an undeclared empty body is about to
replace a file that holds bytes. Carries the size at risk so the caller can be
told what it would have lost (#6414)."""
def __init__(self, path: str, existing_bytes: int):
super().__init__(path)
self.path = path
self.existing_bytes = existing_bytes
class WriteFileTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
lines = content.split("\n", 1)
raw_path = lines[0].strip()
body = lines[1] if len(lines) > 1 else ""
# Only the fenced inline-JSON form can say "this file is meant to be empty":
# the text form's `path\n` and a body a parser dropped look identical here.
declared_clear = False
# Decode JSON-object args (the fenced inline-args shape
# ```write_file {"path": "...", "content": "..."}```), matching
# ReadFileTool above. Without this the whole JSON string becomes the
@@ -307,7 +352,17 @@ class WriteFileTool:
_a = json.loads(_stripped)
if isinstance(_a, dict) and "path" in _a:
raw_path = str(_a.get("path", "")).strip()
body = str(_a.get("content", ""))
_content = _a.get("content")
# A `content` key that is literally an empty (or whitespace-only)
# string is the caller declaring the file should be cleared. A
# missing key or a null is what a parser that lost the body leaves
# behind, so neither declares anything. The old
# `str(_a.get("content", ""))` also turned null into the 4 bytes
# "None", which could be neither refused nor honoured.
declared_clear = isinstance(_content, str) and not _content.strip()
body = "" if _content is None else (
_content if isinstance(_content, str) else str(_content)
)
except (json.JSONDecodeError, TypeError, ValueError):
pass
try:
@@ -325,10 +380,51 @@ class WriteFileTool:
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
if not body.strip() and not declared_clear:
# Empty/whitespace-only writes to an already-empty file are no-ops.
# Avoid reopening in truncating mode: another writer may have added
# data since the read above.
if os.path.isfile(path):
existing_bytes = os.path.getsize(path)
if existing_bytes > 0:
raise _EmptyBodyWouldTruncate(path, existing_bytes)
return old, 0
try:
if body:
# Publish whitespace content atomically. Writing it
# after exclusive creation could overwrite bytes from
# a writer that filled the new placeholder meanwhile.
_write_new_file_without_overwrite(path, body)
else:
# An exact empty body needs no staged data, so create
# the file exclusively and never write through it.
with open(path, "x", encoding="utf-8"):
pass
except FileExistsError:
if os.path.isfile(path):
existing_bytes = os.path.getsize(path)
if existing_bytes > 0:
raise _EmptyBodyWouldTruncate(path, existing_bytes)
return old, 0
raise
return old, len(body)
with open(path, "w", encoding="utf-8") as f:
f.write(body)
return old, len(body)
old_content, size = await asyncio.to_thread(_write)
except _EmptyBodyWouldTruncate as e:
clear_call = json.dumps({"path": raw_path, "content": ""})
return {
"error": (
f"write_file: refused to write an empty body over {e.path} — it holds "
f"{e.existing_bytes} bytes, which the write would have destroyed, so "
f"the file is unchanged. To clear it on purpose, resend with an "
f"explicit empty content: {clear_call}"
),
"exit_code": 1,
}
except PermissionError:
return {"error": f"write_file: {path}: permission denied", "exit_code": 1}
except OSError as e:
+4
View File
@@ -420,6 +420,8 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
return {"error": "Edit needs line 2: memory_id, line 3: new text"}
memory_id = lines[1].strip()
new_text = lines[2].strip()
if not memory_id:
return {"error": "Edit needs line 2: memory_id"}
if not new_text:
return {"error": "New text cannot be empty"}
@@ -453,6 +455,8 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
if len(lines) < 2:
return {"error": "Delete needs line 2: memory_id"}
memory_id = lines[1].strip()
if not memory_id:
return {"error": "Delete needs line 2: memory_id"}
memories = _memory_manager.load_all()
original_len = len(memories)
+4 -1
View File
@@ -664,7 +664,10 @@ def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
elif tool_type in ("grep", "glob", "ls", "edit_file"):
content = json.dumps(args) if args else "{}"
elif tool_type == "write_file":
content = args.get("path", "") + "\n" + args.get("content", "")
# Keep raw OpenAI JSON on the canonical path so explicit empty content
# remains distinguishable from a missing body.
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block(name, json.dumps(args))
elif tool_type == "create_document":
parts = [args.get("title", "Untitled")]
if args.get("language"):
+7 -1
View File
@@ -1442,7 +1442,13 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
elif tool_type == "get_workspace":
content = ""
elif tool_type == "write_file":
content = args.get("path", "") + "\n" + args.get("content", "")
body = args.get("content")
if isinstance(body, str) and body.strip():
content = args.get("path", "") + "\n" + body
else:
# Preserve missing/empty intent for WriteFileTool instead of folding
# all empty shapes into the same path-plus-newline representation.
content = json.dumps(args)
elif tool_type == "edit_file":
content = json.dumps(args)
elif tool_type == "apply_patch":
+58
View File
@@ -0,0 +1,58 @@
import asyncio
import json
import pytest
from src import ai_interaction
from src.memory import MemoryManager
def test_manage_memory_delete_blank_id_rejected(tmp_path, monkeypatch):
"""Calling delete with a blank memory_id should return an error and not delete the first memory."""
memory_file = tmp_path / "memory.json"
initial_memories = [
{"id": "first-mem-1111", "text": "First memory that must not be deleted", "category": "fact", "timestamp": 1000},
{"id": "second-mem-2222", "text": "Second memory", "category": "fact", "timestamp": 2000},
]
memory_file.write_text(json.dumps(initial_memories), encoding="utf-8")
manager = MemoryManager(str(tmp_path))
monkeypatch.setattr(ai_interaction, "_memory_manager", manager)
monkeypatch.setattr(ai_interaction, "_memory_vector", None)
# Calling delete with a blank line before the ID
result = asyncio.run(ai_interaction.do_manage_memory("delete\n\nsecond-mem-2222"))
# Must reject the call
assert "error" in result
assert "memory_id" in result["error"].lower()
# Verify that the first memory was NOT deleted
remaining = manager.load_all()
assert len(remaining) == 2
assert remaining[0]["id"] == "first-mem-1111"
def test_manage_memory_edit_blank_id_rejected(tmp_path, monkeypatch):
"""Calling edit with a blank memory_id should return an error and not overwrite the first memory."""
memory_file = tmp_path / "memory.json"
initial_memories = [
{"id": "first-mem-1111", "text": "First memory original content", "category": "fact", "timestamp": 1000},
{"id": "second-mem-2222", "text": "Second memory", "category": "fact", "timestamp": 2000},
]
memory_file.write_text(json.dumps(initial_memories), encoding="utf-8")
manager = MemoryManager(str(tmp_path))
monkeypatch.setattr(ai_interaction, "_memory_manager", manager)
monkeypatch.setattr(ai_interaction, "_memory_vector", None)
# Calling edit with a blank line before the ID
result = asyncio.run(ai_interaction.do_manage_memory("edit\n\nnew content for second"))
# Must reject the call
assert "error" in result
assert "memory_id" in result["error"].lower()
# Verify that the first memory was NOT overwritten
remaining = manager.load_all()
assert len(remaining) == 2
assert remaining[0]["text"] == "First memory original content"
+349
View File
@@ -0,0 +1,349 @@
"""write_file: an empty body must not truncate a file that holds data (#6414).
The reporter's shape: a model call whose arguments lost their content section
(a #6013-class parser failure) reaches WriteFileTool with an empty body, the
existing file is opened in "w" mode, and the tool answers exit_code=0 with
"Wrote 0 bytes". Each "is refused" test below measures that the bytes at the
path are still there afterwards; each "still works" test guards the write path
this change must not narrow.
"""
import builtins
import json
import os
import re
import tempfile
import pytest
from src import tool_execution as te
from src.agent_tools import ToolBlock
from src.agent_tools.filesystem_tools import EditFileTool, WriteFileTool
from src.tool_schemas import function_call_to_tool_block
from src.tool_parsing import parse_tool_blocks
RECIPE = "# Classic banana cake\n\nMash 3 bananas. Bake 180C for 1 hour.\n"
@pytest.fixture
def target():
"""A fresh directory under the system temp root, which _tool_path_roots allows."""
with tempfile.TemporaryDirectory(prefix="odysseus-6414-") as directory:
yield os.path.join(directory, "classic-banana-cake.md")
def _seed(path, text=RECIPE):
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
return text
def _read(path):
with open(path, encoding="utf-8") as handle:
return handle.read()
def _text_call(path, body=None):
"""The documented text form: first line is the path, the rest is the content."""
return path if body is None else f"{path}\n{body}"
def _json_call(path, **content):
"""The fenced inline-JSON form, which the handler decodes itself."""
return json.dumps({"path": path, **content})
# ── The truncation the issue reports ──────────────────────────────────────
@pytest.mark.asyncio
async def test_empty_body_after_the_path_line_is_refused_and_the_file_survives(target):
_seed(target)
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 1, res
assert _read(target) == RECIPE
@pytest.mark.asyncio
async def test_path_only_call_with_no_content_section_is_refused(target):
"""`lines[1] if len(lines) > 1 else ""` has two producers; this is the no-newline one."""
_seed(target)
res = await WriteFileTool().execute(_text_call(target), {})
assert res["exit_code"] == 1, res
assert _read(target) == RECIPE
@pytest.mark.asyncio
async def test_inline_json_without_a_content_key_is_refused(target):
"""A parser that keeps `path` and drops `content` is the reported failure."""
_seed(target)
res = await WriteFileTool().execute(json.dumps({"path": target}), {})
assert res["exit_code"] == 1, res
assert _read(target) == RECIPE
@pytest.mark.asyncio
async def test_inline_json_null_content_is_refused_and_never_written_as_the_word_none(target):
"""`str(_a.get("content", ""))` on a null turns a lost body into the 4 bytes "None"."""
_seed(target)
res = await WriteFileTool().execute(_json_call(target, content=None), {})
assert res["exit_code"] == 1, res
assert _read(target) == RECIPE
@pytest.mark.asyncio
async def test_whitespace_only_body_is_refused(target):
"""A body that carries no characters is the same failure with padding left in."""
_seed(target)
res = await WriteFileTool().execute(_text_call(target, " \n "), {})
assert res["exit_code"] == 1, res
assert _read(target) == RECIPE
@pytest.mark.asyncio
async def test_non_utf8_target_is_refused_on_its_size_not_on_the_decoded_read(target):
"""The existing read swallows UnicodeDecodeError and answers "", which would let a
binary or latin-1 file look empty to the guard while holding real bytes."""
with open(target, "wb") as handle:
handle.write(b"\xc3\xa9\xe8\xaf\xad\xff\xfe\x00binary-ish payload")
before = os.path.getsize(target)
assert before > 0
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 1, res
assert os.path.getsize(target) == before
@pytest.mark.asyncio
async def test_refusal_creates_no_extra_files_next_to_the_target(target):
_seed(target)
directory = os.path.dirname(target)
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 1, res
assert os.listdir(directory) == [os.path.basename(target)]
@pytest.mark.asyncio
async def test_refusal_names_the_byte_count_and_the_explicit_form(target):
_seed(target)
res = await WriteFileTool().execute(_text_call(target, ""), {})
error = res.get("error", "")
assert str(len(RECIPE)) in error, error
# The caller in a loop has to be able to correct itself in one round.
assert '"content": ""' in error, error
assert "output" not in res, res
@pytest.mark.asyncio
async def test_refusal_suggestion_is_valid_json_for_paths_with_quotes(target):
quoted_path = os.path.join(os.path.dirname(target), 'recipe"draft.md')
_seed(quoted_path)
refused = await WriteFileTool().execute(_text_call(quoted_path, ""), {})
match = re.search(
r"explicit empty content: (\{.*\})$", refused["error"], re.S
)
assert match, refused
suggested_args = json.loads(match.group(1))
assert suggested_args == {"path": quoted_path, "content": ""}
cleared = await WriteFileTool().execute(match.group(1), {})
assert cleared["exit_code"] == 0, cleared
assert os.path.getsize(quoted_path) == 0
@pytest.mark.asyncio
async def test_the_resend_the_refusal_prints_actually_clears_the_file(target):
"""The guidance is only useful if a caller can paste it back verbatim. This reads
the JSON object out of the refusal and runs it as the next call."""
_seed(target)
refused = await WriteFileTool().execute(_text_call(target, ""), {})
resend = re.search(r"\{.*\}", refused["error"], re.S)
assert resend, refused
res = await WriteFileTool().execute(resend.group(0), {})
assert res["exit_code"] == 0, res
assert os.path.getsize(target) == 0
# ── Deliberate writes this change must keep working ───────────────────────
@pytest.mark.asyncio
async def test_explicit_empty_content_in_the_json_form_clears_the_file(target):
""""A deliberate empty-file creation can be made explicit" (the issue's own words):
a `content` key that is literally an empty string is a declaration, not a loss."""
_seed(target)
res = await WriteFileTool().execute(_json_call(target, content=""), {})
assert res["exit_code"] == 0, res
assert os.path.getsize(target) == 0
@pytest.mark.asyncio
async def test_empty_body_on_a_new_path_still_creates_an_empty_file(target):
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 0, res
assert os.path.isfile(target) and os.path.getsize(target) == 0
@pytest.mark.asyncio
async def test_whitespace_only_body_on_a_new_path_preserves_the_requested_content(
target,
):
whitespace = " \n\t"
res = await WriteFileTool().execute(_text_call(target, whitespace), {})
assert res["exit_code"] == 0, res
assert _read(target) == whitespace
@pytest.mark.asyncio
async def test_explicit_whitespace_only_json_content_preserves_the_requested_content(
target,
):
_seed(target)
whitespace = " \t "
res = await WriteFileTool().execute(_json_call(target, content=whitespace), {})
assert res["exit_code"] == 0, res
assert _read(target) == whitespace
@pytest.mark.asyncio
async def test_empty_body_over_an_already_empty_file_succeeds(target):
"""Nothing is at risk, so the guard has nothing to refuse."""
_seed(target, "")
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 0, res
assert os.path.getsize(target) == 0
@pytest.mark.asyncio
async def test_empty_body_does_not_truncate_data_written_after_the_size_check(
target, monkeypatch
):
"""A write racing the size check must survive the empty-body path."""
_seed(target, "")
real_getsize = os.path.getsize
def write_after_size_check(path):
size = real_getsize(path)
if path == target and size == 0:
with open(path, "w", encoding="utf-8") as handle:
handle.write("concurrent update")
return size
monkeypatch.setattr(os.path, "getsize", write_after_size_check)
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 0, res
assert _read(target) == "concurrent update"
@pytest.mark.asyncio
async def test_empty_body_does_not_truncate_a_file_created_after_the_absence_check(
target, monkeypatch
):
"""Exclusive creation must not overwrite a file that appeared during the check."""
real_isfile = os.path.isfile
def create_after_absence_check(path):
exists = real_isfile(path)
if path == target and not exists:
with open(path, "w", encoding="utf-8") as handle:
handle.write("concurrent update")
return False
return exists
monkeypatch.setattr(os.path, "isfile", create_after_absence_check)
res = await WriteFileTool().execute(_text_call(target, ""), {})
assert res["exit_code"] == 1, res
assert _read(target) == "concurrent update"
@pytest.mark.asyncio
async def test_whitespace_body_does_not_clobber_a_concurrent_creation(
target, monkeypatch
):
"""Whitespace on a missing path must not overwrite a competing writer."""
real_open = builtins.open
real_link = os.link
def write_concurrent_content(path):
with real_open(path, "w", encoding="utf-8") as concurrent:
concurrent.write("concurrent update")
def interleaved_open(path, mode="r", *args, **kwargs):
handle = real_open(path, mode, *args, **kwargs)
if path == target and mode == "x":
write_concurrent_content(path)
return handle
def interleaved_link(source, destination, *args, **kwargs):
if destination == target:
write_concurrent_content(destination)
return real_link(source, destination, *args, **kwargs)
monkeypatch.setattr(builtins, "open", interleaved_open)
monkeypatch.setattr(os, "link", interleaved_link)
res = await WriteFileTool().execute(_text_call(target, " \n\t"), {})
assert res["exit_code"] == 1, res
assert _read(target) == "concurrent update"
@pytest.mark.asyncio
async def test_a_real_body_still_writes_and_reports_a_diff(target):
_seed(target)
replacement = "# Classic banana cake\n\nMash 4 bananas.\n"
res = await WriteFileTool().execute(_text_call(target, replacement), {})
assert res["exit_code"] == 0, res
assert _read(target) == replacement
assert res["diff"]["added"] == 1 and res["diff"]["removed"] == 1
@pytest.mark.asyncio
async def test_edit_file_remains_an_explicit_way_to_clear_a_file(target):
"""The route this change leaves open for a caller that cannot reach the fenced
inline-JSON form: replace the whole content with nothing."""
_seed(target)
res = await EditFileTool().execute(
json.dumps({"path": target, "old_string": RECIPE, "new_string": ""}), {}
)
assert res["exit_code"] == 0, res
assert os.path.getsize(target) == 0
@pytest.mark.asyncio
async def test_native_function_call_can_explicitly_clear_a_file(target):
"""The native schema conversion must preserve the explicit empty-content intent."""
_seed(target)
block = function_call_to_tool_block(
"write_file", json.dumps({"path": target, "content": ""})
)
assert block is not None
res = await WriteFileTool().execute(block.content, {})
assert res["exit_code"] == 0, res
assert os.path.getsize(target) == 0
@pytest.mark.asyncio
async def test_raw_openai_function_call_can_explicitly_clear_a_file(target):
"""The raw OpenAI JSON parser must retain explicit empty-content intent too."""
_seed(target)
arguments = json.dumps({"path": target, "content": ""})
raw_call = json.dumps(
{
"type": "function",
"function": {"name": "write_file", "arguments": arguments},
}
)
blocks = parse_tool_blocks(raw_call)
assert len(blocks) == 1
assert blocks[0].tool_type == "write_file"
res = await WriteFileTool().execute(blocks[0].content, {})
assert res["exit_code"] == 0, res
assert os.path.getsize(target) == 0
# ── The live dispatch path, not just the handler ──────────────────────────
@pytest.mark.asyncio
async def test_execute_tool_block_refuses_a_lost_body_without_touching_the_file(target, monkeypatch):
"""#6414 reached the reporter through a parsed model call, so the refusal has to
survive execute_tool_block's wrapping and still report failure upstream."""
_seed(target)
monkeypatch.setattr(te, "_owner_is_admin", lambda owner: True)
_desc, result = await te.execute_tool_block(
ToolBlock("write_file", _text_call(target, "")),
owner="admin",
security_context=te.NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("exit_code") == 1, result
assert _read(target) == RECIPE
+18
View File
@@ -30,6 +30,24 @@ docker compose up -d --build
```
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
**Official Docker images.** The compose files reference the official multi-arch image `ghcr.io/odysseus-dev/odysseus`, which CI (the `ci / docker publish` workflow) publishes on every push to `main` and `dev`. When the image is reachable, Compose pulls it instead of building — so the same files work on hosts without a build toolchain (Portainer stacks, Coolify, etc.). `--build` forces a local build regardless.
Tag scheme:
| Tag | Meaning |
| --- | --- |
| `:latest`, `:X.Y.Z` | Latest curated build from `main`. **Mutable** — re-pushed on every push to `main`, even without a version bump. |
| `:X.Y.Z-<sha>` | Immutable build pin (e.g. `1.0.2-7c8070f`). One tag, one build, forever. **Use this in production.** |
| `:dev`, `:X.Y.Z-dev.<sha>` | Rolling `dev` branch builds; the `<sha>` form is also immutable. |
For production, pin the immutable tag by overriding the image in `.env` (or the stack's environment variables):
```bash
ODYSSEUS_IMAGE=ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f
```
Browse current tags at <https://github.com/odysseus-dev/odysseus/pkgs/container/odysseus>. (Until this package is made public and linked to the repo by an org owner, pulls fall back to the local build automatically — that fallback is intentional.)
Open `http://localhost:7000` when the containers are healthy. Docker Compose
binds the web UI to `127.0.0.1` by default. If the port is taken, set
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`