* fix(skillscan): keep secret literals bound by kwargs, defaults and walrus in view
#5648 replaced the line-oriented `name[:=]value` sweep with an AST walk that
only inspected Assign/AnnAssign. A keyword argument, a parameter default and a
walrus all still read as `name=value` to that sweep, so moving an embedded
credential into a call silenced a HIGH-severity rule.
* fix(skillscan): fold constant-built secret values back into the AST scan
The pre-#5648 text sweep reported `API_KEY = "sk-" + "a1b2c3d4e5f6"`,
because its value capture stopped at the first closing quote of `"sk-`
and the remainder was never examined. Splitting the quotes does not
change the bound value, but `ast.Constant` alone now requires it to:
`+`, an adjacent literal run, and a placeholder-free f-string all bind
a compile-time constant that the HIGH-severity rule no longer sees,
while the same bytes in a file Python cannot parse still trip the text
fallback. `_python_secret_literal` folds those forms, so the AST pass
stays a precision-only change for values as well as binding forms.
Runtime-composed values (`os.environ[...] + "…"`, `%`-formatting) stay
unreported, which is the precision #5648 was after.
* fix(skillscan): fold a literal chain without reaching the recursion limit
The constant fold recursed once per operand, so a concatenation of ~1000
literals raised RecursionError on input ast.parse accepts. That exception
escaped past the per-file analyzer guard in scan_skill_dir, which drops every
finding collected for the file, so the deep chain cost the file its other
findings as well. The fold is now a stack walk that collects operands in
source order for either nesting, and the file's remaining rules keep reporting.
* fix(skillscan): report credential defaults on lambda parameters
_python_secret_bindings walked parameter defaults only for FunctionDef and
AsyncFunctionDef, so a credential moved into a lambda default (positional or
keyword-only) escaped the secret-env-assignment gate even though the
line-oriented sweep this rule replaced reported it. ast.Lambda exposes the same
ast.arguments structure, so add it to the tuple and pin both spellings.
Addresses review feedback on #5691.
---------
Co-authored-by: sxh313 <sxh313@users.noreply.github.com>
_auto_create_postgres_db opens a throwaway engine against the server's
`postgres` database and disposes it in its finally, but that await was bare.
Startup cancellation landing there abandoned a half-disposed pool that nothing
else owned — the engine is a local, and the CREATE DATABASE has already
committed under AUTOCOMMIT, so the next boot does not take this path again.
Drain the dispose through the cancellation-safe await_drained() helper the
engine's own close path already uses, and pin it with a repeated-cancellation
regression.
* fix(logging): collapse space-carrying Redirecting slots (#5225 round 16)
A Redirecting slot was kept verbatim whenever a scheme matched at
position 0, but the generic absolute-URL pass it is handed to stops
both its `host` and `rest` groups at whitespace. A Location header
value carrying an interior space (legal field syntax a misbehaving
server can emit, and the shape round 13 deliberately kept matching)
therefore survived in the clear after the first space:
'Redirecting /private/x?token=Q -> https://cdn.example/other page?sig=LeakedSig'
redacted to '... -> https://cdn.example/<redacted> page?sig=LeakedSig'.
The slot is now kept only when a whitespace-free absolute URL fills it,
so the pass is guaranteed to consume it whole; space-carrying slots
collapse like every other shape neither pass could cover.
* fix(logging): ask the URL pass whether it consumes a Redirecting slot whole (#5225 round 16 review)
The kept-set regex ([a-zA-Z...]*://\S+) was a stand-in for the invariant
"the generic absolute-URL pass consumes the slot whole", and two
whitespace-free absolute shapes satisfied it while the pass actually
stopped early: a quote that reads as a closing mark (https://h/a')b?sig=…)
and an empty host before the first /?# (https:///path?sig=…), which its
host group never matches. Both left the signed tail in the log.
Replace the stand-in with the check itself: run the pass's own pattern
against the slot and keep it only when the match spans it end to end, so
the rule cannot drift from the pattern's stop conditions again.
---------
Co-authored-by: sxh313 <sxh313@users.noreply.github.com>
* chore(checkpoint): bump to checkpoint 4.2.0 and drop the stale saver patch
langgraph-checkpoint 4.2.0 fixes the write dropped after a full -> delta
migration (upstream langchain-ai/langgraph#8526) and keeps its own
InMemorySaver override, so the compatibility patch in checkpoint_patches.py
could neither detect the fix nor stand down: it read langgraph's version
while InMemorySaver ships in langgraph-checkpoint, and it would have kept
shadowing the upstream implementation on 4.2.0. The postgres release 3.1.2
locates plain-value delta seeds (upstream #8535).
- floor langgraph-checkpoint>=4.2.0,<5.0 in the harness and raise the postgres
extra to >=3.1.2,<3.2; langgraph's own >=4.1.0 would otherwise let a
resolver reintroduce the bug now that the patch is gone
- delete the patch, its version guard, and the three implementation-assertion
tests; test_full_to_delta_migration_replays_on_same_thread stays as the gate
and fails on 4.1.1 without the patch (verified by a one-off unpatch run)
- langgraph stays 1.2.9 and langgraph-checkpoint-sqlite stays 3.1.1
Verification: make lint; delta checkpointer/cache/worker-resume suites green
with the migration gate passing on memory and sqlite (postgres params skip
without TEST_POSTGRES_URI). The full make test run before the packaging-pin
fix below was 13 failed / 18523 passed; 12 of those failures reproduce on main
with the same environment (git stash baseline, identical failure sets), 7 of
them because the local config.yaml runs checkpoint_channel_mode: delta. The
remaining one, the postgres-extra string pin in tests/test_checkpointer.py, is
updated here.
Upstream status for the surrounding gaps: cache parity across off/cold/warm
states, postgres pagination (langchain-ai/langgraph#8448) and parallel-superstep
replay order (#8382) are still open upstream, as is the abandoned-branch fork
fix (#8548) that the delta resume linearization in runtime/runs/worker.py works
around, so that path stays.
* docs(changelog): keep checkpoint upgrade in Unreleased
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
The Gateway's staged upload commit links the `.part` file to its final name and
then unlinks the staged name. The conversion path duplicates a descriptor on the
staged inode *before* that commit (deliberately: it is what keeps conversion
reading the bytes this request wrote rather than whatever the committed name
points at afterwards), so the staged file still has an open handle when the
commit tries to remove it.
POSIX allows that; Windows does not. Uploading a convertible document
(`uploads.auto_convert_documents: true`, a PDF/DOCX/PPTX/XLSX upload) on a
Windows host therefore failed with a 500:
Failed to upload report.pdf: [WinError 32] The process cannot access the
file because it is being used by another process
Split publishing from removing the staged name: `_commit_upload_temp_no_overwrite`
now takes `unlink_staged`, and the ingestion service keeps ownership of the
staged path whenever it still holds the conversion descriptor, removing it once
that descriptor is released. The same best-effort removal replaces the
`os.unlink` in `_abort_upload_temp`, where an abandoned duplication worker can
hold the inode open and turn a cancellation into a secondary permission error.
Both removals are best-effort: a staged name that cannot be removed yet is
already hidden from every upload listing, and the Gateway sweeps leftovers on
startup.
Verified on Windows (Python 3.12):
- the new regression test reproduces the sharing violation portably (it pins the
staged name while the descriptor lives) and fails on main with the exact 500
above; it passes here
- `pytest tests/test_uploads_router.py tests/test_project_documents_promotion.py
tests/blocking_io/test_project_documents_promotion.py` -> 88 passed
(16 of those failed on main on this host)
- `ruff check` / `ruff format --check` clean
Co-authored-by: Shxiao101 <Shxiao101@users.noreply.github.com>
`CONTRIBUTING.md` documents `make docker-init` as "Build the custom k3s image
(with pre-cached sandbox image)" and lists building Docker images, installing
frontend/backend dependencies, and sharing the pnpm cache as its effects. None
of that is what the target does: `Makefile`'s `docker-init` runs
`scripts/docker.sh init`, which only checks `docker images` and runs
`docker pull` for the all-in-one sandbox image (skipping even that in local
sandbox mode). It never calls `docker build`, and no k3s image exists anywhere
in the repo. The root Makefile's own help text already says
"make docker-init - Pull the sandbox image".
The same misconception makes the registry-override note wrong: `UV_INDEX_URL`
and `NPM_REGISTRY` are consumed as build args by `backend/Dockerfile` and
`frontend/Dockerfile`, i.e. during the `docker compose up --build` that
`make docker-start` performs, so they cannot affect a pull-only target.
Correct the setup step, the command comment, and the registry note. No code
change.
Verified against the checkout:
- `Select-String -Path scripts/docker.sh -Pattern 'docker build|docker pull'`
-> only `docker pull "$SANDBOX_IMAGE"` (line 281); no `docker build`
- `Select-String -Path Makefile -Pattern 'docker-init'` -> line 57 help text
"Pull the sandbox image"; line 187 target -> `scripts/docker.sh init`
- `Select-String -Path backend/Dockerfile,frontend/Dockerfile -Pattern
'UV_INDEX_URL|NPM_REGISTRY'` -> both declared as build ARGs
- `backend/tests/test_client_live_policy.py::test_documentation_matches_live_test_commands`
(the only test that reads CONTRIBUTING.md) still passes; it asserts
"make test-live", the live opt-in variable, and "API", none of which this
touches.
Co-authored-by: Shxiao101 <Shxiao101@users.noreply.github.com>
* fix(skills): resolve the user-scoped install scan from its own config
UserScopedSkillStorage.ainstall_skill_from_archive re-implements the parent
body to redirect the install target into the per-user custom root, and the
copy dropped the app_config argument. _scan_skill_archive_contents_or_raise
therefore fell back to the process-global get_app_config() for the static
scan, while the archive preflight it inherits still read self._app_config --
so the two gates of a single install could consult different configs.
The divergence is reachable whenever the storage outlives a config edit:
DeerFlowClient snapshots get_app_config() at construction, and the Gateway
hands its per-request get_config() to get_or_new_user_skill_storage, while
get_app_config() hot-reloads a later edit of config.yaml. With the storage's
config enabling skill_scan and the global disabling it, the preflight ran,
the content scan silently skipped, and a CRITICAL archive installed.
* fix(skills): thread app_config into the per-file LLM scan
_scan_skill_archive_contents_or_raise takes app_config and uses it for the
static gate, but never forwarded it to _scan_skill_file_or_raise, so the
per-file LLM scan fell back to the process-global get_app_config() for
skill_evolution.moderation_model_name and the model it constructs. Under
the stale-snapshot scenario the content-scan fix addresses, the two halves
of one install still read two different configs.
Raised in review of #5703 by willem-bd. The gateway's own skill-write
routes already pass app_config to scan_skill_content; this brings the
install path in line.
* feat(image-search): expose the color and license_image filters
`_search_images` accepts `color` and `license_image` and forwards them into the
`f` filter payload that `ddgs`'s duckduckgo_images engine builds
(`duckduckgo_images.py:62-79`), so the provider already declares support for
both. But `image_search_tool` never took them as parameters and never passed
them, so no caller -- model or config -- could set either one. They were dead
parameters: declared, wired, and unreachable.
Both matter for this tool's stated purpose, which is sourcing reference images
for image generation: `color` narrows results to the palette being generated,
and `license_image` filters to license-cleared results when the reference will
be redistributed.
Expose them on `image_search_tool` alongside the existing `size` /
`type_image` / `layout` filters and pass them through. `_search_images` only
forwards truthy filters, so an unset filter still produces the same request as
before.
Adds two regression tests: one asserting both filters reach the DDGS call
(red on main with `KeyError: 'color'`), one pinning that unset filters stay out.
* fix(image-search): add a usage hint to the color filter docstring
The sibling filters each carry a usage hint ("Use \"Large\" for reference
images", "Use \"photo\" for realistic references"), but `color` only listed
its options. Since this docstring is the model's parameter surface, add a
short hint so the model knows when the filter applies, and note that
"color" means full-color rather than a meta-parameter.
---------
Co-authored-by: RXQ6 <RXQ6@users.noreply.github.com>
In grouped mode the sidebar indents a project's thread rows with a nested
SidebarMenu carrying ml-4, but SidebarMenu defaults to w-full. Width 100%
plus a 16px left margin overflows the sidebar by 16px, so the absolutely
positioned row kebab (right-1) lands past the visible edge and gets
clipped. The Archived group nests one level deeper and overflows 32px,
hiding its kebabs entirely.
Swap w-full for w-auto on both indented menus so the block-level flex
container fills the remaining width minus its margin. A dom test renders
ProjectsSection in grouped mode from seeded query caches and pins that
every indented menu drops w-full while the root menu keeps it.
Fixes#5681
_receive_single_file dropped an attachment with zero log lines whenever
_download_by_code returned None (or empty bytes) - each None reason
already has its accurate line inside the download function (non-200
exchange, missing downloadUrl, oversize abort, transport failure), but
nothing tied the skip to the file being received, and the empty-bytes
case landed in the same silent branch. The caller now logs a neutral
guard line naming the file - the same shape the WeChat channel's callers
and the manager reader use since #5225.
Both the None and empty-bytes paths are pinned by caplog tests;
reverting to the silent branch turns them red. Full DingTalk suite
135 passed / 1 skipped.
* fix(skills): rollback reads its history off the event loop
rollback_custom_skill constructed the user-scoped storage, probed whether the
skill exists and parsed custom/.history/<name>.jsonl inline, while the sibling
get_custom_skill_history two lines above already offloads exactly those three
steps and documents them as "blocking filesystem IO that must stay off the
event loop". The history file grows one entry per edit carrying full previous
and new content, so a rollback request parsed the whole edit history on the
Gateway loop; the strict Blockbuster gate catches the construction as a blocking
os.getcwd() through SkillsConfig.get_skills_path() -> project_root().
Offload the three steps through the same worker-thread closure and anchor both
pre-scan branches under tests/blocking_io/.
* test(skills): address rollback anchor review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(subagents): report an explicit zero batch limit instead of defaulting it
SubagentBatchService.submit resolved max_live_items / max_running_items with
`or`, so a caller that explicitly passed 0 got the configured default
(100 / 3) persisted and the 1..N range guards never saw the value, while a
negative already failed there. Resolve the defaults on 'is None' so an
explicit 0 reaches the guard that names it.
* docs(subagents): state the >= 1 window constraint in the batch_task args
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(searxng): walk pageno so max_results above one page is honored
The SearXNG search API has no `limit` parameter: `/search` answers with one
page of results (the instance's `results_per_page`, 10 by default) and
ignores a limit it is handed. The client sent `limit=max_results` anyway and
hardcoded `pageno=1`, so any configured `max_results` larger than a page was
silently truncated to whatever the first page held -- an instance could not
tell a truncated response from a complete one.
Collect results by walking `pageno` until `max_results` is reached, a page
adds nothing new, or a page comes back empty. The walk is capped at
`_MAX_PAGES` so an unexpectedly large `max_results` cannot fan out into
unbounded requests. The unsupported `limit` parameter is no longer sent.
Adds four regression tests covering cross-page collection, the single-request
fast path, dedup-driven early stop, and the absence of `limit`.
* Remove redundant condition for limit check
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: RXQ6 <RXQ6@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(tenki): honour start_line and end_line in read_file
The base Sandbox contract declares read_file(path, start_line, end_line),
and the tools layer passes both keyword arguments on every ranged read —
including the continuation path that a truncated read names with its
start_line marker. TenkiSandbox.read_file accepted only path, so any
ranged read through a Tenki sandbox raised TypeError and surfaced as
"Unexpected error reading file"; a truncated read could not be continued.
Slice the text the way the other providers do (e2b, opensandbox,
boxlite): the full text when no range is given, otherwise the selected
lines joined with newlines.
* fix(tenki): clamp a negative start_line and pin the out-of-range read contract
Mirror LocalSandbox.read_file and clamp the start line to at least 1 so a
negative start_line cannot wrap around through Python's negative-index
slicing. Also extend the ranged-read test with the two boundary cases the
tools layer depends on: a start past EOF returns an empty string, and a
negative start reads from the first line.
* fix(tenki): clamp a negative end_line in read_file
Mirror LocalSandbox.read_file for the symmetric range boundary: clamp a
negative end_line to zero so Python's negative-index slicing cannot silently
drop the last line. Pin the empty result in the existing ranged-read test.
backend/app/gateway/AGENTS.md reached 49,155 bytes against the
49,152-byte hard limit, so tests/test_agent_guidance_check.py fails on
main for every change, not just ones that touch the file.
This trims wording I added to the Uploads row in #5547, #5611 and #5673
rather than anyone else's documented invariants: the delete clause keeps
both facts — symlinks 404, a converted .md is kept — without restating
that the 404 matches GET /list.
That clears the overage with 13 bytes to spare, which is not much. The
file is effectively full, so the next addition needs a real slimming
pass or a budget decision.
* fix(channels): fix Telegram inbound file download and sandbox readability
- Shut down the download Bot on the Telegram loop (Bot.shutdown(), which
closes its HTTPX clients) before the loop stops, instead of the
non-existent Bot.session.close().
- Grant group/other read on channel-downloaded uploads so the non-root
AIO/Docker sandbox process can read the root-written 0o600 file.
- Apply the sandbox permission change with os.fchmod on a descriptor
opened with O_NOFOLLOW (validated as a regular file via fstat), bound
to the validated upload inode, so a symlink swapped in after lstat
cannot redirect the chmod to a target outside the uploads directory.
The open also uses O_NONBLOCK so a sandbox-swapped FIFO cannot block the
read-only open before the regular-file check (matching the existing
open_upload_file_no_symlink convention).
Centralized in a shared apply_upload_sandbox_permits helper reused by
the channel inbound path and the HTTP upload readable/writable helpers.
- Surface the download failure cause chain in logs with the Bot API URL
masked: the configured token is redacted and both URL forms are
collapsed, covering the file download URL (/file/bot<token>/...) and
the method URLs (/bot<token>/getMe, /bot<token>/getFile).
- Migrate the existing receive_file tests onto the download Bot and add
coverage for _get_download_bot (loop-bound creation + caching, cleanup
on init failure), download-bot routing over the application bot,
real-Bot shutdown closing both HTTPX clients, receive_file
timeout-containment, masked cause-chain logging (file and method URL
forms), and inbound-file sandbox perms (including the swap-after-lstat
symlink regression).
* fix(uploads): surface sandbox permission failures
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): bound remaining shell session cleanup requests
release_command_scope(), close()'s scoped-session drain, and close()'s recovery-session cleanup called shell.cleanup_session() without a request budget, so a stalled cleanup could hold the thread-key serializer, scoped.lock, or the sandbox lock for the SDK's default transport budget. Pass the existing _bounded_cleanup_request_options() (5s, max_retries=0) at those three sites; _cleanup_session_best_effort() and its swallow-and-log contract are unchanged, as are create_session and list_dir lifetimes.
* fix(sandbox): bound AIO list_dir with a directory deadline
Give AioSandbox.list_dir its own 60s directory deadline with a 65s no-retry
host envelope, independent of bash_command_timeout. Preserve #5634's
shell-generation selection: list_dir runs on the current recovery session once
the implicit shell is fenced, and an ambiguous list_dir outcome - transport
timeout or an ambiguous returned status - fences whichever generation actually
executed the request, dropping local recovery ownership and attempting bounded
best-effort cleanup instead of leaving that session reusable. hard_timeout
remains definite termination and keeps the targeted session reusable. Only
completed/None results are parsed, so a partial find is never returned as a
complete listing.
Session creation RPC lifetime remains out of scope.
* fix(sandbox): recover sessions after transport failures
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
web_fetch_tool parsed `wait_for_timeout_ms` with a bare int(), so any value
that is not already an int raised ValueError and aborted the whole tool:
a quoted number in YAML, or a typo like `2s`, turned every fetch into
"Error: invalid literal for int() with base 10". web_capture_tool reads the
same documented key through the tolerant `_as_int` helper two functions
above, so the two tools disagreed about the same config key.
While aligning them, web_fetch_tool now also reads
`wait_for_selector_timeout_ms` instead of hardcoding 5000, so a
`wait_for_selector` on a slow page can be given more than five seconds --
web_capture_tool already honours the key and the client has always
accepted it.
Co-authored-by: RXQ6 <RXQ6@users.noreply.github.com>
* fix(skills): install for a user keeps its custom-dir setup off the event loop
UserScopedSkillStorage.ainstall_skill_from_archive re-implemented the install
pipeline and created the per-user custom directory inline, so the await behind
POST /api/skills/install blocked the Gateway loop on os.mkdir. The base class
offloads every filesystem phase around its LLM scan — and says so — but the
override's setup step sat above that comment and escaped it, and the existing
blocking-IO anchor drove only the host-scoped class.
Offload the mkdir through the same worker thread and anchor the override itself
under the strict Blockbuster gate.
* test(blocking-io): pin per-user staging of supporting skill files
Review follow-up on #5650: the user-scoped anchor asserted only the staged
SKILL.md, so a regression that mis-stages the references/ tree under the
per-user root would pass both anchors. Assert the supporting file against the
per-user layout as well, mirroring the host-scoped twin.
* docs(zh): add the missing Reading a Referenced Conversation section to README_zh
Adds the 'Reading a Referenced Conversation' section to the Chinese README,
mirroring the section #5465 added to the English README. Same position
(between Context Engineering and Long-Term Memory), translated rather than
paraphrased — including the read_conversation / conversation_references
run fields, the context.conversation_references SDK fallback, the web
'引用会话' (Reference a conversation) button, and both doc links (targets
verified to exist).
The English TOC does not list this section either, so the zh TOC is left
unchanged to stay consistent.
* docs(zh): address conversation reference review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* Initial plan
* docs(gateway): trim AGENTS guidance to stay within hard budget
Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: WillemJiang <219644+WillemJiang@users.noreply.github.com>
* fix: rebuild todo reminders after context compaction
* chore: remove implementation plan from PR
* refactor: share todo reminder message name
* docs: keep agent guidance within CI size budgets
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(skills): render an empty allowed-tools as no tools, not as all
`_render_skill_metadata` truthiness-tested `Skill.allowed_tools`, so an
explicitly empty allowlist (`allowed-tools: []`, parsed to `()` by
`parse_allowed_tools`) rendered "Allowed tools: (all)" -- the same text an
omitted field (`None`, unrestricted) produces. `allowed_tool_names_for_skills`
distinguishes the two and strips every business tool for `()`, so the
describe_skill output contradicted the policy applied to the same skill.
* docs(skills): scope the rendered (all) to the skill's own declaration
Review note: allowed_tool_names_for_skills makes a legacy None skill
contribute no tools once any loaded skill declares allowed-tools, so
"(all)" on that line reports the frontmatter of this skill rather than
the tool set the middleware will allow in a mixed set. Say so where the
tri-state is rendered; behaviour is unchanged.
* docs(config): move the Sandbox banner above the sandbox key in config.example.yaml
The "Sandbox Configuration" banner and the "Option 1: Local Sandbox"
comment sat directly above the `uploads:` block, twenty lines away from
the `sandbox:` key they describe. Give `uploads:` its own banner and
move the sandbox banner down to the key it introduces. Comments only;
no key or value changes.
* Enhance sandbox configuration options
Updated sandbox configuration options to include BoxLite micro-VM.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(uploads): stop deleting a converted companion we cannot prove we wrote
Conversion names a document's Markdown companion after the document's
stem and falls back to a _N suffix when that name is already taken, so
the .md beside a document may belong to another document sharing the
stem, or to the user. Delete removed it anyway: uploading a.docx and
a.pdf produces a.md and a_1.md, and deleting a.pdf destroyed a.docx's
companion while orphaning a.pdf's own.
Delete now removes only the file it was asked to remove. The companion
stays listed and can be deleted on its own. Orphans are the cost of not
guessing; issue #5672 covers giving companions a provable owner, which
is what a safe cleanup needs, along with the two related readers that
still guess (the outline injected for a document and the agent's file
listing).
convertible_extensions loses its last use and is dropped from the
signature and both call sites. The gateway router keeps importing
CONVERTIBLE_EXTENSIONS for the ingestion bridge and now declares it in
__all__, where that module documents its re-exports.
* docs(changelog): note that delete keeps the converted markdown (#5673)
* fix(runtime): share one change position across an atomic thread operation
MemoryRunStore.create_thread_operation_atomic marked each row it touched
separately, so one interrupt-and-replace consumed two positions in the
(change_seq, run_id) cursor that list_changed consumers page with. The SQL
store allocates a single position for the whole set, and runtime/AGENTS.md
documents that as the contract for an atomic thread operation.
The memory store is the backend an install runs whenever no durable database
is configured, so a cursor reader could observe the interrupted row and its
replacement under two positions instead of one, ordered by run_id.
Allocate one position after the raise-only scan -- so a rejected operation
still consumes nothing -- and reuse it for the claimed rows and the new row.
* test(runtime): pin that a rejected thread operation consumes no position
create_thread_operation_atomic allocates its change position only after the
raise-only candidate scan, so a ConflictError consumes nothing -- the memory
store's counterpart of the SQL store rolling back and leaving its clock
untouched. Nothing asserted that ordering.
A consumed-but-unused position leaves no trace in the rows themselves, so a
test comparing list_changed output passes even with the allocation hoisted
above the scan. Assert instead on the position the next accepted operation
lands on: that surfaces a hoist as a gap.
* fix(sandbox): enforce AIO shell command timeouts
Map Sandbox.execute_command(timeout=T) onto the AIO legacy shell as a server-side hard_timeout=T plus a bounded host request (ceil(T+5)s, max_retries=0), preserve and render the upstream status (hard_timeout -> terminated + Exit Code: 124; no_change_timeout -> may still be running), keep no_change_timeout from preempting hard_timeout, and restrict ErrorObservation replay to completed results.
* fix(sandbox): contain ambiguous AIO command outcomes
Treat a transport timeout and statuses terminated/no_change_timeout/unknown as ambiguous: never replay the current command, fence the scoped or recovery session generation with bounded best-effort cleanup, and never adopt a replacement session for those statuses. hard_timeout and completed keep the session reusable. Align the env-bearing bash.exec path with the same contract and make its retry result authoritative.
* fix(sandbox): wire AIO command timeout from provider config
Restore bash_tool's non-local behaviour (other providers keep their own defaults) and let AioSandboxProvider read sandbox.bash_command_timeout as the sandbox's default_command_timeout at every construction site, with explicit per-call timeouts still winning. Widen the config field to float with allow_inf_nan=False and read the provider value with the module fallback so partial-config providers keep working.
* docs(sandbox): document AIO command timeout semantics
Document the provider-scoped deadline (LocalSandbox/AioSandbox/OpenSandbox wire it; other providers keep their defaults), the semver-vs-frozen-:latest image behaviour, the no-replay guarantees, and trim the sandbox guidance back under its size budget.
* docs(sandbox): scope the wedge-resistance claim to command requests
willem-bd's review on #5634 reproduced that list_dir and both create_session call sites still make unbounded SDK requests while holding the same sandbox lock, so the guide must not read as if every AIO request is bounded. State that the bounded T+5s host wait applies to command requests and name session-creation and file/list RPCs as not covered; bounding those remains a separate operation-deadline/control-plane follow-up.
* fix(sandbox): make list_dir honor the shell generation fence
list_dir previously always targeted the implicit persistent shell, so after an ambiguous command outcome fenced that generation (#5634) a listing could re-enter it. Route default-shell target selection through _ensure_default_shell_session_id so both execute_command and list_dir reuse the explicit recovery session, creating one only when the implicit shell is fenced. No list_dir deadline, hard_timeout, request budget, retry, or status semantics are added; those remain scoped to #5644.
* test(sandbox): share search contracts across providers
* test(sandbox): make search provider groups explicit
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(mcp): honor configured stdio working directories
* fix(mcp): preserve defaults for empty working directories
Treat empty stdio cwd values as omitted, including unresolved environment references. Add real subprocess regressions for discovery and pooled-call defaults, plus direct connection-dictionary coverage.
A Redirecting slot stayed verbatim unless it started with "/", but the
Location field-value grammar (RFC 3986 relative-part) also admits
slash-less relative references: 'Redirecting /private/x?token=Q ->
download?sign=LeakedSig' rendered the signed query verbatim, and neither
the slot rule nor the generic absolute-URL pass (which needs a scheme)
could see it. A slot is now kept only when a scheme matches at position
0 (left for the generic pass to rewrite); everything else collapses -
slash-less relative paths, query-only and fragment-only forms,
network-path references (collapsing any userinfo they carry), and
non-hierarchical schemes such as data: URIs. The ^Redirecting prefix
anchor is unchanged, so non-URL '-> /path' arrows (sandbox mount
mappings) keep passing through untouched.
Regression table covers all relative-reference forms plus the absolute
regression anchor; mutation-verified (reverting to startswith('/') goes
red). Aligned with the fix sketched in the #5225 round-15 review thread.
* fix(skillscan): read Python secret assignments from the AST
The `secret-env-assignment` rule swept every text file with a
`name[:=]value` regex, which misreads Python syntax in two ways:
- `def __init__(self, token: Optional[str] = None):` — the captured
"value" is a type annotation, not embedded secret material.
- `api_key = os.getenv("MINIMAX_API_KEY")` — reading a secret from the
environment is this rule's own documented remediation, yet it was
reported as a hardcoded credential.
Both are HIGH severity, so they map to a review `error` and fail the
Skill Review gate. Two bundled public skills therefore failed CI on an
unchanged checkout:
- skills/public/github-deep-research/scripts/github_api.py:56
- skills/public/music-generation/scripts/generate.py:27
Python sources now go through the AST instead of the line-oriented
sweep, keeping only real literal values. The text sweep is unchanged for
config, shell, YAML, and Markdown. Annotated assignments are still
reported, and now at the literal rather than at the annotation.
Tests: `secret-env-assignment` previously had no coverage anywhere in
backend/tests. Added six tests, including a regression test that scans
every bundled public skill script. Verified red on main and green here.
* fix(skillscan): keep text coverage for unparseable Python
Reviewer feedback on #5648: when `ast.parse` failed, the rule returned no
findings at all. One syntax error -- or a NUL byte, which `ast.parse` rejects
with the same exception -- therefore silenced the HIGH-severity
`secret-env-assignment` rule for the whole file, where `main` still swept the
raw text and reported it. For a review-gate rule that is a trivial evasion.
The line-oriented sweep moves into `_scan_secret_assignments_by_text`, which
the non-Python path now calls and which `_scan_python_secret_assignments` falls
back to when the file will not parse. Parseable files keep the precise AST
semantics this change introduces; unparseable ones keep main-level coverage
instead of losing the rule entirely.
Tests: both fallback paths added (syntax error, NUL byte); both fail before this
commit and pass after.