Untrusted repos must not define stdio MCP servers that spawn at session open. Skip <.coworker/mcp.json> until the workspace is trusted, matching allowed_commands consent.
Fixes#213
The collision-rename loop in email_download_attachment used
`target.stem.rstrip('-0123456789')`, which strips every trailing digit and
dash, not just a previously-appended `-N` suffix. So saving a second
`invoice_2024.pdf` produced `invoice_-1.pdf`, `IMG_20240115.jpg` became
`IMG_-1.jpg`, and Outlook's `image001.png` became `image-1.png`.
Strip only a trailing `-<number>` suffix with a regex, preserving the
original digits (and still incrementing correctly on repeated collisions).
When the approved turn completes, mark_idle spawns the auto-title completion
as a fire-and-forget task running provider.complete via asyncio.to_thread. The
scripted E2EProvider records that call (then raises on the exhausted turn
queue, which autotitle swallows), so one extra provider.calls entry lands at a
nondeterministic time - frequently between the calls_before snapshot and the
'provider not re-invoked' assertion in the mute step. Wait for SID to leave
mgr._autotitle_inflight (the settling idiom test_autotitle.py already uses)
before snapshotting, so the title call is deterministically included in the
baseline. The mute guarantee is asserted exactly as before. Verified 15/15.
The test forces an every-minute cron task due and sleeps a fixed 0.2s with
tick_seconds=0.05. After the first (catch-up) run the scheduler correctly
advances next_run to the next minute boundary; when the test happens to start
within ~0.2s of a boundary, that boundary falls inside the sleep window and
the task legitimately fires a second time, failing 'assert ran == [t.id]'.
Harness race, not a scheduler bug (the running-guard and post-run advance are
correct). Wait on an event set by the fake runner and stop the scheduler
immediately after the first run - the advance is synchronous once the runner
returns, so no second tick can fire. Verified 15/15 plus an adversarial
shifted-clock repro at the minute boundary.
'Connect with' segmented choice (API key / profile / IAM keys) shows only that
method's fields; non-selected fields are dropped at build so stale values can't leak.
New optional field: paste the console-generated key, no CLI/IAM setup needed.
Takes precedence over SigV4 credentials, matching boto3; live-tested on Converse.
resolve_from_reply decided allow/deny with 'in' checks on the whole message,
so "disallow" resolved as allow (checked first, and it contains "allow") and
replies containing words like "note" or "not" resolved as deny instead of
being recorded as free-text answers. Since this gates parked unattended
actions, a false allow is the worst-case direction.
Keyword intent now requires word boundaries (with the common -d forms added);
emoji checks stay as substring matches. Anything that matches neither list
falls through to the existing free-text path, which records the reply verbatim
instead of acting on it.
Test button and saved pill follow the required-secret field (or the first field for
cloud providers); Bedrock/Vertex add-model rows get a family selector.
gemini/ and claude/ ids reuse the native providers; openweight/ goes through the
MaaS OpenAI-compat endpoint with an auto-refreshed google-auth bearer.
Credentials: service-account JSON or Application Default Credentials.
claude/ ids use Anthropic's native Bedrock client; everything else goes via Converse.
Credentials: explicit keys, named profile (incl. SSO), or the ambient AWS chain.
tauri-build validates every bundle.resources path on each cargo build, dev
included, but binaries/sidecar is only staged by the release scripts and
/binaries is gitignored — so `npm run tauri dev` on a fresh checkout failed
with `resource path 'binaries/sidecar' doesn't exist`.
Create the empty placeholder in the build script. Dev needs no packaged
sidecar (server_bin() falls back to the venv server) and tauri-utils skips
empty resource directories, so the bundle is unchanged. Putting it in
build.rs rather than setup_dev_env.sh also covers Windows, where the bash
bootstrap script never runs.
Fixes#131
Complete the remaining Simplified Chinese UI copy, align runtime interpolation arguments with locale placeholders, and render translated rich text through Trans. Add contract tests so placeholder drift and unresolved values fail in CI.
micromark percent-encodes non-ASCII characters in link hrefs, so an
artifact link to a Chinese/Japanese/Korean filename dispatched the encoded
path and the backend 404ed on the literal %E6… name. Decode the path (and
strip a leading slash so it stays workspace-relative) before opening.
Introduces react-i18next across the GUI (fixes the frontend portion of #121):
all user-facing strings in ~50 components move from hardcoded English
literals to t() keys, with complete en and zh-Hans locale files under
src/locales/. English remains the default; the language follows the system
locale unless the user picks one explicitly in the new Settings > General
language switcher. Vitest initializes i18n with the English resources so
existing assertions keep passing.
The picker gates ollama:* on a live local probe, so this passed only where Ollama runs and failed in CI.
The probe's own behaviour stays covered by test_ollama_models_gated_on_liveness.
ConversationStore.save() appends new messages on the common path, but when a
turn reduces the message count (context compaction / summarization) it rewrites
the whole .jsonl with open(..., "w"), which truncates the file at open(). A crash
mid-rewrite then leaves a truncated or empty log, permanently losing the
conversation history.
Write the reduced log to a temp file and replace() it in one atomic step -- the
same tmp-then-replace pattern subscriptions.ChannelBuffer._save() already uses.
Add tests/test_conversation_atomicity.py covering the crash path (history
preserved when the write fails partway) and the happy path (reduced set
persisted, no leftover temp file).
`_read_jsonl` parsed every line with a bare `json.loads` inside a list
comprehension, so a single malformed line raised `JSONDecodeError` and
took the whole `load()` down. An append interrupted mid-write (process
crash, full disk) leaves exactly that: one truncated trailing line — and
from then on every surface that opens the session errors on load, with
no way back short of hand-editing the file. The session is effectively
bricked, including its recoverable history.
Skip unparseable lines and keep the good messages. This matches how the
rest of this module already treats JSON (the inline-blob and roots/grants
loaders all swallow `JSONDecodeError` and fall back) — `_read_jsonl` was
the one strict outlier on the hot session-load path.
A session id is joined straight into a filesystem path (`<id>.jsonl`),
and session ids come from client-controlled surfaces — the
`/ws/session/{session_id}` WebSocket route and REST paths all take the
id from the URL. Nothing validated it, so an id like `../evil` escaped
the conversations directory: `ConversationStore.save(SessionRecord(
session_id="../evil", ...))` wrote `evil.jsonl` one level ABOVE
`conversations/`, and a crafted id could clobber or place files
elsewhere under the state dir.
`_file()` — the single chokepoint every conversation-file path flows
through — now rejects ids that aren't a safe single path component and
confirms the resolved path stays inside `conv_dir`. The accepted
charset (`[A-Za-z0-9_-]{1,128}`) is a superset of every id the app
generates (uuid4 hex, and the `__run__`/`__task__`-prefixed automation
threads), so no legitimate session is affected; `load()` of an unknown
or unsafe id still returns None (the DB lookup misses before any file
IO), not an error.
The public `is_safe_session_id` helper is exported so the one other
site that turns a session id into a path — `_provision_scratch` in the
server manager — can reuse the same guard in a follow-up.
Two schedule bugs, both in the default-local, everyday path.
1. DST: one-time 'local' tasks fired at the wrong wall-clock across a DST
boundary (coworker/automation/store.py)
_tz("local") returned datetime.now().astimezone().tzinfo, a FIXED offset
equal to whatever was in effect at compute time. A "once" task created in
summer (EDT, -04:00) for a winter date (EST, -05:00) bound the naive
datetime to -04:00, so 08:00 fired at 07:00 - an hour early - and for a
one-shot task next_run is computed once at creation and never self-heals.
Fix: _tz returns None for 'local' (and for an unknown IANA name) instead of
a frozen offset, and the naive datetime is left naive. datetime.timestamp()
and croniter over a naive local base apply the correct local DST offset for
the actual fire date via the C library. Named IANA zones still anchor via
ZoneInfo. No new dependency.
2. Weekday labels were a day late (coworker/automation/models.py)
Cron day-of-week is 0/7=Sunday, 1=Monday..6=Saturday, but _DOW started at
Monday, so `_DOW[int(dow) % 7]` rendered dow 1 (Monday) as "Tuesday" and
dow 0 (Sunday) as "Monday" on every weekly automation card. Reordered the
list to start at Sunday to match cron semantics.
Regression tests added; an existing assertion that encoded the off-by-one
("Monday" for cron dow 0) is corrected to "Sunday".
Boundary-hardening pass addressing three audit findings on the local sidecar.
Shell command allowlist (andrewyng/openworker#28):
- Replace prefix-string matching in PermissionEngine._command_allowed with
argv-aware matching: reject any command containing shell operators
(; & | > < ` $( ( and newlines) before consulting the allowlist, then require
the allowlisted entry's tokens to be an exact argv prefix. This closes the
auto-run bypass where an allowlisted "git status" also auto-ran
"git status && rm -rf ~", pipes, redirection, and command substitution.
- Drop language interpreters / package managers (python, python3, node, npm,
npx) from DEFAULT_ALLOWED_COMMANDS — allowlisting an interpreter allowlists
arbitrary code (python3 -c "..."), defeating approval gating. Read-only
inspection commands and pytest remain.
MCP OAuth loopback (andrewyng/openworker#29):
- Verify the OAuth state at the loopback boundary. The MCP SDK already validates
state (compare_digest), so this is not a CSRF fix but defense-in-depth: capture
the state from the authorize URL and have deliver_callback ignore a callback
whose state does not match WITHOUT consuming the pending future, so a stray or
forged local hit can no longer abort a user's in-progress sign-in. Falls back to
prior accept-any behavior when no state was captured.
WebSocket ingestion caps (andrewyng/openworker#38):
- Bound a single user_message frame in the session WS loop: max text length,
max attachment count, and max total attachment bytes. Oversized frames get a
visible error frame and are dropped instead of being buffered into a turn; the
socket stays alive. Guards the unauthenticated loopback socket against cheap
memory spikes.
Tests:
- Allowlist: reject operator chaining (8 variants), argv-boundary matching, and
interpreters-not-auto-allowed-by-default.
- OAuth: state extraction, and mismatched/missing state ignored without consuming
the flow while the matching state still resolves it.
- WS: oversized text and too-many-attachments rejected with an error frame, and a
normal message still works afterwards.
Full suite: 865 passed (1 pre-existing unrelated failure in
test_provider_router::test_manager_curated_models, present on origin/main).
Switch notices no longer consume the retry guard or hide the button.
Error, switch model, Retry is the intended recovery path; retry runs on the new model.
Splash shows the real 6-point OpenWorker star, not the 4-point sparkle glyph.
Settings reload after the health check lands, so the picker can't stay on Loading models.