Refs/heads/patch 5 - #157
Open
sithu209 wants to merge 249 commits into
Open
Conversation
…eekday labels
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".
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.
`_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.
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).
Introduces react-i18next across the GUI (fixes the frontend portion of liuxb99#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.
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.
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.
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 liuxb99#131
… boundaries 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.
…e mute assertions 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 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).
The background-shell poll helper (_poll_output) returned the instant it saw status=='exited', but shell_task_output reads are incremental and the final output chunk can still be draining into the task buffer on the same tick the status flips to exited. Under load the first post-exit read returns empty and the helper returns acc='', failing 'quick_done' in acc. This is a harness race, not a shell bug (the incremental-read contract is correct). Keep reading until a read yields nothing new (bounded 2s) after the terminal status so the tail is captured deterministically. Verified 15/15.
…nswer Small local models drift off the tool-call format, especially with a large tool schema in play. Two failures followed from that, both of which ended the turn looking like the model had simply stopped mid-sentence. Salvage handled well-formed Qwen/Hermes XML but gave up entirely on a call cut off partway through. It now takes the function name plus every parameter that actually closed. A trailing unterminated `<parameter=…>` is dropped rather than guessed, so a half-written path or file body can never reach a tool; if that leaves a required argument missing, the call fails validation and the model gets a corrective tool error, which is the agent loop working. Worse, a call that never parsed at all was reported as `status: completed` — indistinguishable from the model deciding it was done, leaving the user with narration trailing off into stray closing tags. It now ends on the error path, so the GUI offers Retry. That is the right affordance here: the drift is probabilistic, not deterministic, so the same model usually succeeds on a second attempt. Detection ignores fenced and inline code, so a model *explaining* tool-call syntax is still a real answer, and requires that tools were offered at all. Reported against qwen3.5-9b on LM Studio, 2026-07-26. Co-Authored-By: Claude <noreply@anthropic.com>
Per review: drain output greedily (consume anything already buffered with no sleep) and only sleep between EMPTY reads, stopping after two consecutive empties (robust to a one-tick gap between chunks). Bound cut 2.0s -> 0.5s. Common case is now a couple of reads rather than a 0.02s poll that could, worst case, read up to ~100 times; also removes the break-on-first-empty fragility. Verified 20/20.
…9#143) Both writers in secrets.py created the temp with Path.write_text and only restricted it afterwards: tmp.write_text(json.dumps(store, indent=2)) # umask default -> 0644 _restrict_to_user(tmp, is_dir=False) # chmod 0600, too late os.replace(tmp, self.path) Measured on macOS by sampling the mode at the moment _restrict_to_user is called, i.e. while the file already holds the plaintext: before: temp held the plaintext at 0o644 -> WORLD/GROUP READABLE after: temp held the plaintext at 0o600 -> user-only Every local process could read every API key for the length of the write, as could anything indexing or backing up that directory. Fixed by routing both writers through _atomic_private_write, which uses tempfile.mkstemp: the file is created 0600 and empty, the Windows ACL is applied while it is still empty, and only then is the content written. Two further problems fall out of the same change, both proven by tests that fail on the previous code: The temp name was the fixed <name>.tmp. Pre-creating that path as a symlink redirected the write, so an unrelated file was overwritten with the contents of secrets.json - an arbitrary overwrite that also discloses every key to a location the attacker picked. mkstemp uses O_EXCL and a random suffix, so a pre-existing path is not followed. A write that failed partway left the temp behind. It is now removed on any exception and the previous secrets.json is left intact. Tests: tests/test_secrets_file_mode.py. Three of the seven fail on the previous code (mid-write mode, hostile temp symlink, cleanup after a failed write); the rest pin the final mode and round-tripping.
The session WebSocket handler at api.ts:1824 called JSON.parse without a try/catch. A single malformed frame from the server would throw an uncaught exception inside the browser event handler, silently killing the onmessage callback — the session would freeze with no error feedback. The sibling connectEvents handler at api.ts:1471 already wraps its JSON.parse in try/catch. This mirrors that same pattern.
…awned run A tick's due() snapshot still lists a task whose run is parked on an approval — next_run only advances on completion. The overlap guard lived inside the spawned coroutine, so when an approval landed just before a tick, the parked run could finish and clear the guard before the duplicate spawn took its first step, and the task ran twice. This is the intermittent 'assert 2 == 1' in test_blocked_run_does_not_stall_other_tasks on main's CI. The new regression test forces that interleaving deterministically.
An inbound channel reply resolving an inbox item matched decision words as substrings anywhere in the text, with allow words checked first. Two consequences: - A free-text answer to a question was hijacked whenever it merely contained a keyword: "I have no preference - use us-east-1" resolved the item as "deny" instead of the answer; "yesterday's numbers look fine" resolved as "allow". - A negated approval reply flipped to an approval: "I cannot approve this yet" contains "approve", so the pending action was ALLOWED. Intent now comes from the reply's leading word (or emoji) after the [ow:...] token is stripped: "Yes, go ahead", "No.", "deny", a bare thumbs-up all still resolve as before. Anything else falls through to the free-text path, which inbox_approver already maps to deny - the safe default for an approval gate.
Both tools were registered with kind="read" in TOOL_DEFS, so approval_for_tool() returned False and overrode the approval=True set at the call site. The permission engine then classified them as READ (requires_approval=False → RiskClass.READ), auto-allowing them without ever prompting the user — even though both write to disk (clone creates a new directory, pull fast-forwards an existing repo) and their own descriptions say "Requires user approval". The connector list API (tool_dicts) always reports requires_approval=True, so the UI showed them as gated while the runtime silently bypassed the gate — a mismatch that made the bug invisible to users. Reclassify both as kind="write" so the §36 kind→approval mapping correctly gates them.
…029) Per-session coworker+folder chips replace the sidebar split-button picker; code family gets a send-time folder dialog with git-ready temp dirs and Save as project. Builtins ship enabled; user-facing noun is Coworker; personas flag now defaults on.
Chat ships disabled+unsurfaced (Coworker covers quick Q&A); recoverable from Settings.
Bundle skills/ dir joins the persona's session menu (additive; user disables/mutes win); manifest skills: narrows the bundle; mcp: scopes raw servers. Install snapshot now carries the skills folder — the sharing bundle shape.
Security, Cloud Posture, and Dependency Audit coworkers as self-contained bundle dirs (manifest + skills) driving OSS scanners; registry loads bundle subdirs; packaging includes them.
Freezes today's evaluate() verdict across 26 (mode, tool, args, grants) situations, so any later permission change shows up as a row-diff. Four rows are marked BASELINE-WRONG / BASELINE-ANNOYING on purpose: they record known gaps (shell auto with no sandbox, find -delete and find -exec auto-allowed via a find prefix, git status && git diff rejected for the operator, web_fetch never gating in any mode). The PRs that fix these flip their rows here as the visible proof. Design of record: ocw-context/docs/reviewed-auto-mode.md Parts 3 and 7.
…writes Three gate defects, each verified by direct execution before and after. 1. web_fetch was RiskClass.READ, so is_consequential() was False and evaluate() returned allow on its third rung -- before any rule, mode or PDP, in EVERY mode including plan/discuss. A URL's query string carries data outbound, so this was an ungated egress path. New RiskClass.EGRESS covers model-chosen network reads; web_search stays READ (fixed configured provider, not a model-chosen host). Adds an allowed_domains allowlist (exact host or subdomain; 'evil-python.org' never matches 'python.org'), a session-scoped "always allow this domain" grant, and ApprovalOutcome.ALWAYS_DOMAIN. 2. A risk override could DOWNGRADE a built-in: marking write_file as read made is_write False (skipping path scoping) and consequential False (skipping the read-only gate) at once -- one settings line disabling two protections, in every future session. Overrides may now only tighten a built-in write/exec/ egress tool; relaxing a metadata/MCP tool (the intended use) still works. 3. Path scoping read a literal "path" argument, so apply_patch and apply_unified_diff -- whose paths live inside the patch/diff blob -- were never scoped at all. write_paths() extracts them from the blob and scopes every one; a write whose path cannot be located now fails closed to approval rather than slipping through auto/custom unscoped. allowed_domains is user-global only, alongside auto_allow: a cloned repo must not be able to widen the agent's network reach. Golden matrix: web_fetch interactive allow->ask, plan allow->deny, plus new egress/patch rows (31 rows green). test_permissions_risk's override test asserted the old downgrade behavior and is updated to the tightening rule. Full suite: 22 failures, all pre-existing on the unmodified tree (boto3 absent, Windows symlink privilege, Slack socket timeouts) -- none introduced here. Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
in-project files that execute later Two floors, both mode-independent. 1. The settings files (config.toml, risk_overrides.json, workspace_trust.json, unattended.json, coworker.db which holds the saved grants, secrets.json, inbox_routing.json) cannot be modified by any tool, in any mode. The check runs BEFORE mode, allowlists and every auto-approve path, and returns a hard refusal rather than an approvable prompt -- loosening requires editing the files out-of-band. The escalation this blocks: approve one ordinary-looking `python setup.py`, it quietly appends to risk_overrides.json, and every future session is more permissive. That happens in the DEFAULT interactive mode, so the protection cannot be a property of a sandbox or of any single mode. Covered for write tools (resolved path), patch/diff blobs (path extracted), and run_shell (command text). Shell matching is deliberately full-path only: an earlier draft also matched bare filenames, which hard-denied any command merely mentioning `secrets.json` -- caught by test_shell_commands_not_auto_allowed_by_default, which reads that path with `cat`. Shell is parser depth: it stops accidents and casual attempts, not a determined adversary. That needs the OS sandbox (OPE-14). 2. Files inside the workspace that run on a later, innocuous action -- .git/hooks/**, .github/workflows/**, .vscode/tasks.json, .coworker/** -- stay writable but never WITHOUT a human. Auto mode, custom-mode auto_allow and session "always allow" all lose to this. Otherwise accept-edits is a clean bypass: write .git/hooks/pre-commit, then run an innocuous git commit. 19 new tests, incl. every mode parametrized and a lookalike case (docs/pre-commit.md stays ordinary). 167 permission-related tests green. test_standing_approvals::test_blocked_run_does_not_stall_other_tasks is an intermittent pre-existing scheduler timing flake (fails ~1 in 3 on the unmodified tree). Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
The old rule -- any shell operator disqualifies the whole command -- was wrong
in both directions, verified by running it:
find . -delete -> ALLOW (destructive, no prompt)
find . -exec rm {} + -> ALLOW (destructive, no prompt)
git status && git diff -> ask (two allowed reads, refused)
It judged punctuation rather than danger. `-delete` and `-exec` need no
separator, so a bare `find` prefix auto-ran them; meanwhile two independently
allowed reads were refused for containing `&&`.
Now:
- Constructs whose contents we cannot evaluate -- substitution, redirection,
variable expansion, grouping -- still disqualify the whole command, because
the unexamined tail after a prefix match must only ever be arguments.
- Compound commands are split on &&, ||, ;, |, |&, & and newlines, and EVERY
part must be independently covered by an allowlist entry.
- Parts that run code named in their arguments are never prefix-eligible:
argument executors (xargs, sudo, timeout, env, docker, npx, ssh...),
interpreters carrying inline code (python -c, bash -c, node -e), and
execution/deletion flags (-exec, -execdir, -delete, -ok).
- Matching stays on parsed words, so `git status` covers `git status -s` but
never `git statusfoo` or a bare `git`.
Splitting is textual and does not respect quoted separators. That is
deliberate: over-splitting yields MORE parts to justify, never fewer, so it
cannot loosen a verdict.
37 new tests including metamorphic cases (spacing, quoting, absolute program
path must not loosen `find . -delete`). Golden matrix: three rows flip as
intended, two added. 164 permission tests green.
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 2 (CMD-1/3/4).
1. Grant validation. POST /v1/inbox/{id}/resolve takes a raw resolution string
and approval_outcome() previously honoured whatever it named. The GUI
deliberately withholds the tool-wide "always allow" for run_shell (the
command-scoped grant is the narrower option), for save_skill (every skill
proposal gets its own review) and for connectors; Slack mirrors render only
approve/deny. So any local API caller could mint a session-wide,
any-argument shell grant -- a vocabulary the design says must not exist.
_grant_offered() now mirrors the card's own rules on the server and
downgrades an unoffered grant to a one-time approval, writing a
`grant_refused` audit row. Applied to the "always" channel vocabulary too,
so a Slack reply cannot mint what the in-app card would refuse. MCP tools
are covered alongside connectors: they are not category=connector but are
external, and the grant would be unbounded over every future argument.
A failed always_task mint is now audited rather than silently downgraded.
2. Autonomy transitions. Mode changes (WS set_mode) and the unattended toggle
were unrecorded, so "who turned on auto mode, and when" was unanswerable
from the audit store -- at odds with the per-call trail the engine keeps
everywhere else. Both now write an audit row tagged raised/lowered, so
autonomy increases can be filtered. set_unattended moves onto the manager
so REST and any future surface record it the same way; no-op flips are not
recorded.
15 new tests. test_server's two failures are pre-existing on the unmodified
tree (Windows file-permission errors in pathlib), unrelated to this change.
Design of record: ocw-context/docs/reviewed-auto-mode.md Part 3.
The title rides the user message the moment it lands — a long agentic turn no longer holds the session name hostage; an opener signature keeps the completion hook (background turns) from burning duplicate attempts.
Specialists offer starting points as an ask_user (free text stays available) so a picked option briefs the reviewer; title generation adds the agent first reply as evidence with a third attempt window.
Pre-release fixes from the 0.2.0 walkthrough
Catalog-checked: 1,048,576 ctx, tool calling; matrix cap 60->65.
Add Ox Alpha via OpenRouter; bump to 0.2.1
… 400 errors When a turn is interrupted at the wrong moment, the append-only JSONL can end up with a user message between an assistant tool_calls block and its tool result. Providers reject this ordering (Anthropic 400/2013, OpenAI 'tool_call_ids did not have response messages'), making the session permanently unrecoverable from the UI. _repair_tool_pairing() runs in ConversationStore.load() and: - moves a real tool result found later in the thread to sit right after its call - synthesises a placeholder result for a call with no matching tool message - is idempotent — well-formed threads pass through unchanged Fixes andrewyng#331
The repair was synthesising placeholder tool results for any dangling call, including calls in the last assistant message that are simply pending (interrupted for approval/question). This broke durable resume because the provider saw the placeholder and thought the tool already ran, so resolve_inbox could not re-execute the tool. Now trailing calls (assistant tool_calls as the last message with no result) are left untouched — the engine will resume them. Placeholders are only injected when the thread has moved past the call, proving it is corrupt rather than pending. Fixes test_durable_resume_question and test_durable_resume_approval_executes_tool.
…RITY.md README gains a use-cases section led by the security coworkers and a governed-by-design section describing floors, the autonomy ladder, and audit provenance. SECURITY.md adds vulnerability disclosure via security@openworker.com.
Updating README
Require attachment reads to resolve through an actor-visible item in the requested board space. Record authoritative attachment provenance so forged comment or transition refs cannot grant blob access, while preserving legacy refs through an atomic one-time migration. BREAKING CHANGE: BoardDialect.attachment and the /v1/board/attachment endpoint now require a board space.
Re-applies the react-i18next wrapping onto main's components and covers the ones added since; count keys use _one/_other plural forms. Locale tests enforce en/zh key parity and placeholder contracts.
…tem-auth security(teams): enforce worker visibility on board item reads
…d-auth fix(teams): enforce attachment read authorization
Folder-count summary gets _one/_other; keyboard shortcuts register at commit so early input isn't dropped during boot; restores main's copy for the files-location help and the onboarding signed-in line.
Add GUI internationalization with English and Simplified Chinese
…tale-tick-double-run fix: scheduled task can run twice when an approval lands on a tick
…bstring-intent Parse inbox reply intent from the leading word, not substrings
…path-traversal Reject path-traversal session ids in the conversation store
…stream Recover truncated tool calls, and never pass a leaked one off as an answer
…sonl-on-load Tolerate a corrupt line when loading a conversation .jsonl
…rink Make the conversation-log shrink rewrite atomic (prevent history loss on a mid-write crash)
…ng-on-load-331 fix: repair tool-call/result pairing on load to prevent unrecoverable 400 errors
lecise
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ok