Skip to content

server: add initial tool isolation support (via docker) - #26507

Merged
ngxson merged 11 commits into
masterfrom
xsn/server_docker_isolate
Aug 8, 2026
Merged

server: add initial tool isolation support (via docker)#26507
ngxson merged 11 commits into
masterfrom
xsn/server_docker_isolate

Conversation

@ngxson

@ngxson ngxson commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Overview

Add initial support for tool call isolation, by proxying calls to docker container via sh comand

Example usage:

llama-server --agent --tools-runtime docker:ubuntu:jammy
image

For frontend change (should be planned for a future release, cc @allozaur )

  • frontend can provide a docker container ID via x-tool-docker
  • the idea is that frontend can use the exec_shell tool on host machine to list/create/delete containers on-demand

Requirements

@ngxson
ngxson requested review from a team and ggerganov as code owners August 3, 2026 09:54
@github-actions github-actions Bot added documentation Improvements or additions to documentation server labels Aug 3, 2026
@ngxson

ngxson commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

cc @ServeurpersoCom may worth doing some security audits on this

/bot review

@ggml-gh-bot

ggml-gh-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown
Automated code review

I've reviewed the diff and surrounding code. Here is my review.

Summary

This PR adds experimental Docker-based tool isolation to llama-server via a new --tools-runtime flag and an x-tool-docker request header. It refactors the existing subprocess loop into a shared run_subprocess helper and introduces a tools_io_docker backend plus a server_tools_docker_runtime container owner. Tests are included. The approach is reasonable and well-scoped to the server, but there is one correctness bug on the respawn path, an input-validation gap on the client-supplied container id, and a few backend-inconsistency nits.

Blocking

(point 1) server_tools_docker_runtime::spawn() leaks the previous process handle on respawn. In get_container_id(), when a spawned container has died you call spawn() again, which calls proc.create(...) on a common_subproc that is already in the is_created state (proc.alive() is false only because the docker run client exited; the handle was never joined/destroyed). common_subproc::create() overwrites subprocess_s proc without calling subprocess_destroy (common/subproc.cpp), so on POSIX this leaves an unreaped zombie and on Windows leaks the process handle on every respawn. Fix by reaping the dead proc before re-creating, e.g. call proc.join() (which resets is_created) at the start of spawn() when is_created is true.

Will slow the review

(point 2) The x-tool-docker header value is passed straight into docker exec/docker cp/docker inspect argv with no format validation (server-tools.cpp, the handle_post lambda and tools_io_docker). A client-supplied value beginning with - (e.g. --user=root, --privileged) is parsed as a docker option rather than a container id, which is an option-injection vector. The header is deliberately client-settable per the PR, so it is attacker-controlled. Validate it against a container-id/id-prefix pattern (e.g. ^[A-Za-z0-9][A-Za-z0-9_.-]*$) before use, and reject otherwise. This is the main security-adjacent finding; it is mitigated by the "do not enable in untrusted environments" warning, but a cheap bounds check is warranted given the header is explicitly exposed.

(point 3) Existing-container mode runs docker inspect on every single tool call (get_container_id() -> is_running()), under the runtime mutex. For a chatty agent this adds a docker round-trip and serializes all tool dispatch. Consider caching the liveness result with a short TTL, or only re-checking on a failure. Spawned mode is fine (cheap proc.alive()).

(point 4) tools_io_docker::read_file differs from tools_io_basic::read_file in two ways that are worth aligning or documenting: (a) it caps at SERVER_TOOL_DOCKER_READ_FILE_MAX_SIZE (64 MB) and, on overflow, silently returns the truncated bytes with a [output truncated] suffix as the file content (exit code is still 0 so read_file returns true); the basic backend reads the whole file. (b) run_subprocess reads stdout with fgets + strlen(buf), so a NUL byte in the file truncates the content at that point. Text reads are the intended use, but the two backends behave differently for edge cases; at minimum document the limit, and consider reading with a binary-safe loop for the docker cat path.

(point 5) tools_io_docker::list_files does not filter git ls-files output through is_regular_file, unlike tools_io_basic::list_files which drops stale/deleted staged entries. The docker path can therefore return paths that no longer exist on disk. Either apply the same is_regular_file filter or rely solely on the find fallback for consistency.

(point 6) Unrelated doc changes are bundled in. The removal of the --rpc rows from tools/cli/README.md, tools/completion/README.md, and tools/server/README.md, and the addition of --mcp-servers-config / --mcp-servers-json rows to tools/server/README.md, have nothing to do with Docker tool isolation. Per CONTRIBUTING.md these should go in a separate doc/README-regen PR so this one stays single-purpose.

(point 7) No linked issue. This is a nontrivial feature (new runtime subsystem, new public header option, new request header, container lifecycle management). CONTRIBUTING.md asks that features start as an issue for discussion before a PR; please link the design discussion, or open one, so the approach (shelling out to the docker CLI, the x-tool-docker override semantics, cleanup-on-exit contract) gets agreed upfront.

Nits

(point 8) tools_io_docker::write_file creates the host temp file with a predictable name (llama-tools-io-docker-<thread-hash>-<n>.tmp) in the shared temp dir. On a multi-user host this is a symlink-race surface; prefer mkstemp / an anonymous temp and avoid the predictable pattern.

(point 9) The docker list_files fallback duplicates the junk_dir_names set as a separate prune_names[] array. If one list changes the other will drift; consider hoisting junk_dir_names to a shared free function and reusing it.

(point 10) run_subprocess leaves the child's stderr pipe unread when combine_stderr=false (the read_file and cat/wc/find helpers). A helper that writes more than the pipe capacity to stderr will block until the 15s supervisory timeout fires. Helpers are small today, so this is just a heads-up; if a future helper may emit large stderr, read it or inherit it.

(point 11) Minor: exec_shell_command on a docker target uses timeout <n>s sh -c <command>, and the host supervisory timeout is timeout_secs + 5. When the caller passes timeout=0 (currently meaning "unlimited" for GNU timeout), the host side becomes 5s and kills a long-running command. The basic backend already has its own 0-timeout behavior, but please confirm the intended semantics for timeout=0 across both backends and document it.

This review was generated automatically by pi coding agent using zai-org/GLM-5.2. It may contain mistakes. Maintainers make the final call.

@ServeurpersoCom

ServeurpersoCom commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

cc @ServeurpersoCom may worth doing some security audits on this

/bot review

Now we're getting down to serious business! I'd really prefer Podman over Docker, but the advantage of Docker is that it might make things easier on Windows! We'll take a look at that, I've got the means to test it out, too!

Off the top of my head, without having read the code, Docker requires root access, so it isn't secure by design; however, it does simplify things, and the isolation is more than sufficient for local LLMs especially since running Podman in rootless mode still requires root privileges for the initial installation anyway.
Docker is so similar to Podman that both can be supported without issues. I check this.

@ngxson

ngxson commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

yes, agree on podman, I planned to add it via a follow-up PR. it should be as simple as changing some command templates (and plus, we can also support ssh: that allow everything to pass though a ssh connection)

for now, let's focus on having a solid base implementation first

Comment thread common/arg.cpp
Comment on lines +3329 to +3330
" 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit\n"
" 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit\n",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can have podman: and ssh: in near future

@ServeurpersoCom ServeurpersoCom Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea :

SSH connector is exactly what would let me run this (and always have build-in tool enabled) at home and actually test it, without polluting my production box or bringing KVM into the picture.

SSH solves two things at once. On Windows it removes the whole WSL2 cross platform story that Docker drags in, since OpenSSH ships with the system and nothing else needs installing. And it makes the runtime at the far end somebody else's problem: docker, podman, VMware, KVM, a spare Raspberry Pi, whatever the user already runs.

That is really why I would not put ssh: next to docker: and podman: as a third sibling. It is not another runtime, it is a transport, and the two compose: podman on a remote host is what I actually want. Worth settling before the grammar freezes.

A small SSH only PR would have been a must, and quick to merge.

WDYT ?

Comment thread tools/server/server-tools.cpp Outdated
bool ok = shell_run({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\"", "_", abs_path});
if (ok) {
auto res = run_subprocess(
{"docker", "cp", tmp.string(), container_id + ":" + abs_path},

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for podman and other runtime, it should be as simple as swapping out this command template

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

I'm taking a little time to set up a KVM sandbox on my host server

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

Merged master and :

tools_io_docker::list_files() becomes list_entries(), with a new find_entries(), because master changed the abstract interface in the meantime.

git ls-files first when only files are asked for, otherwise a find pass in the container, junk directories listed but never descended into, and truncated wired to the docker exec timeout.

Three details worth mentioning in passing: the console_output_to_utf8() calls moved into run_subprocess() since you factored out a body that carried them, the two versions of get_info combined, and the junk directory list now duplicated between tools_io_basic and the docker class.

// the isolate is created, mounted, and torn down externally by the caller
// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout
class tools_io_isolate : public tools_io {
public:

@ngxson ngxson Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ServeurpersoCom I abstract POSIX tool wrapper to this tools_io_isolate, so that SSH and podman support can be added in follow-up PR without duplicating too much code from docker code path

If you are interested, you can start working with tool_io_ssh / tool_io_podman in a dedicated branch, I will have a look after the current PR is merged

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also just note that for SSH, we currently only support linux destination, not sure if it worth expanding the support in the future

@ServeurpersoCom

ServeurpersoCom commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

I'm doing my tinkering here; SSH is working (still need to check how robust it is against disconnections), and Podman is currently being tested.

xsn/server_docker_isolate...ServeurpersoCom:llama.cpp:pascal/ssh-and-podman

@ServeurpersoCom ServeurpersoCom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I like the architecture. I got SSH working and first podman testing, proof that it's scalable.

@ngxson
ngxson merged commit dd2c7c4 into master Aug 8, 2026
27 of 35 checks passed
@ServeurpersoCom ServeurpersoCom mentioned this pull request Aug 10, 2026
ServeurpersoCom added a commit to ServeurpersoCom/llama.cpp that referenced this pull request Aug 10, 2026
Follow-up ggml-org#26507. The container runtime drives docker and podman
through one implementation, so parametrize the availability helper,
the container fixture and the attach test on the engine, and cover
both engine prefixes in the container id injection test. Each engine
skips on its own when it is not installed.

The spawn cleanup test stays docker only: it recovers the spawned id
from the container hostname, which docker sets to the short id and
podman rootless does not guarantee. Podman keeps its coverage through
the attach path.
ServeurpersoCom added a commit to ServeurpersoCom/llama.cpp that referenced this pull request Aug 10, 2026
Follow-up ggml-org#26507. create() writes over the handle it is given, so a
respawn after the container died on its own leaked the pipes and the
process handle of the previous one.
ngxson added a commit that referenced this pull request Aug 10, 2026
#26774)

* server: add an ssh transport to the tools runtime

--tools-runtime ssh:<target> runs the built-in tools on a remote host,
where target is whatever ssh already resolves, a user@host or a config
alias, so no credentials live in llama.cpp.

Only build_argv and upload differ from the docker transport: the remote
shell re-parses the command line, so the argv travels through
shell_quote_join, and files go over scp with the same quoting on the
remote path. Authentication is key-based and the host key must already
be trusted, since the tools run without a console and any prompt would
hang them.

The target is validated before use. The spec can reach us from the
x-tool-runtime header, and a leading dash would turn it into an ssh
option, which is enough to run a command back on the host.

Nothing is created and nothing is reclaimed, so an ssh spec goes
straight to the tool call instead of through the container runtime.

Note that this is remoting rather than isolation: the tools can do
whatever the target account can do, and the isolation is whatever runs
them on the far side.

* server: support podman in the tools runtime

docker and podman expose the same run, exec, cp and inspect verbs with the
same argument order, so a single implementation drives both and the engine
is carried by the spec prefix: podman:<image> and podman-container:<id> sit
next to the docker forms.

tools_io_docker becomes tools_io_container and the runtime spawner becomes
server_tools_container_runtime, both holding the client binary chosen at
parse time. A single parse_container_runtime() resolves every spec, so
adding another engine is one string in the table.

make_tools_io() now rejects the spawning forms. The spec also reaches it
from the x-tool-runtime header, which is client controlled, and only the
runtime that owns a container is allowed to create one: a tool call can
attach to a running container, nothing more.

* ./build/bin/llama-gen-docs

* server: simplify the tools runtime and drop the file copy step

A server_tools_runtime base with one virtual spec() replaces the
container runtime and the bare spec string that ssh needed next to it,
so server_tools is back to a single pointer and neither setup nor the
handler tests which of the two is set.

write_file used to spill its content into a temporary file on the host
and copy it in, because run_subprocess had no way to feed a child. It
now takes an optional stdin payload and creates the parent directory
and the file in a single round trip through a shell in the isolate.

That removes the upload virtual and both implementations: no more
container cp or scp, no second binary on the host, no sftp subsystem on
the target, no predictable temporary in a shared tmp, and none of the
content reaching an argv the remote shell re-parses. It also fixes
write_file over ssh, which never worked: scp speaks sftp and takes the
remote path literally, so quoting it kept the quotes in the file name.

Writing the payload before reading the output relies on the child
draining stdin as it goes, which holds for cat, its only user today.

* ./build/bin/llama-gen-docs

* server: harden the tools runtime against argv injection and a stdin stall

Validate the container id from x-tool-runtime and --tools-runtime the
same way the ssh target already is, so an id shaped like an option
(docker-container:--privileged) is rejected before it reaches the
engine's exec command line instead of running against a hardened
container. Feed the child's stdin after the watchdog is armed, so a
transport that stalls mid-write is terminated at the deadline rather
than blocking the request forever.

Cover both guards and fix the unknown-scheme test, which used ssh: as
its example and now names a real runtime.

* tests: exercise the tools runtime tests on podman as well as docker

Follow-up #26507. The container runtime drives docker and podman
through one implementation, so parametrize the availability helper,
the container fixture and the attach test on the engine, and cover
both engine prefixes in the container id injection test. Each engine
skips on its own when it is not installed.

The spawn cleanup test stays docker only: it recovers the spawned id
from the container hostname, which docker sets to the short id and
podman rootless does not guarantee. Podman keeps its coverage through
the attach path.

* server: release the container handle before respawning

Follow-up #26507. create() writes over the handle it is given, so a
respawn after the container died on its own leaked the pipes and the
process handle of the previous one.

* server: trim the tools runtime comments

* server: read tool output as raw bytes and harden the runtime on Windows

The stdout pipe is read with read() instead of fgets(), so a chunk
can hold any byte, including NUL, and still streams as soon as data
is available. Past the size cap the pipe keeps draining so the child
never blocks on a full pipe. Both pipe fds are forced to binary mode
on Windows, where the CRT defaults them to text mode and translates
line endings in both directions. Stdin is now always closed after
the feed: the child reads a deterministic EOF, and the Windows
docker and ssh clients stop outliving their command on a stdin pipe
that never closes.

The attach form of --tools-runtime has no lifecycle to own, so it
becomes a static target validated once at startup. This removes the
 subprocess that ran on every tool call and
serialized calls behind a mutex; a stopped container now surfaces
the engine's own error at exec time.

The cidfile path is passed as UTF-8, matching the encoding the
subprocess layer expects for the CreateProcessW command line, so
the spawn form works from a non-ASCII Windows profile.

The SIGPIPE note in server.cpp now names the tools runtime children
as well as the MCP ones.

* clean up comments

* less pollute global scope

* nits

* tests: name the container image after both engines

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
miltos22 pushed a commit to miltos22/llama.cpp-wackMall-merge-request that referenced this pull request Aug 10, 2026
* server: add initial tool isolation support (via docker)

* add docs

* adapt get_info

* py: fix type check

* cont

* separate tools_io_sandbox / tools_io_docker

* rename sandbox --> isolate

* x-tool-docker --> x-tool-runtime

---------

Co-authored-by: Pascal <admin@serveurperso.com>
satindergrewal pushed a commit to satindergrewal/llama.cpp that referenced this pull request Aug 12, 2026
* server: add initial tool isolation support (via docker)

* add docs

* adapt get_info

* py: fix type check

* cont

* separate tools_io_sandbox / tools_io_docker

* rename sandbox --> isolate

* x-tool-docker --> x-tool-runtime

---------

Co-authored-by: Pascal <admin@serveurperso.com>
huaxel pushed a commit to huaxel/CachyLLama that referenced this pull request Aug 12, 2026
* server: add initial tool isolation support (via docker)

* add docs

* adapt get_info

* py: fix type check

* cont

* separate tools_io_sandbox / tools_io_docker

* rename sandbox --> isolate

* x-tool-docker --> x-tool-runtime

---------

Co-authored-by: Pascal <admin@serveurperso.com>
huaxel pushed a commit to huaxel/CachyLLama that referenced this pull request Aug 12, 2026
ggml-org#26774)

* server: add an ssh transport to the tools runtime

--tools-runtime ssh:<target> runs the built-in tools on a remote host,
where target is whatever ssh already resolves, a user@host or a config
alias, so no credentials live in llama.cpp.

Only build_argv and upload differ from the docker transport: the remote
shell re-parses the command line, so the argv travels through
shell_quote_join, and files go over scp with the same quoting on the
remote path. Authentication is key-based and the host key must already
be trusted, since the tools run without a console and any prompt would
hang them.

The target is validated before use. The spec can reach us from the
x-tool-runtime header, and a leading dash would turn it into an ssh
option, which is enough to run a command back on the host.

Nothing is created and nothing is reclaimed, so an ssh spec goes
straight to the tool call instead of through the container runtime.

Note that this is remoting rather than isolation: the tools can do
whatever the target account can do, and the isolation is whatever runs
them on the far side.

* server: support podman in the tools runtime

docker and podman expose the same run, exec, cp and inspect verbs with the
same argument order, so a single implementation drives both and the engine
is carried by the spec prefix: podman:<image> and podman-container:<id> sit
next to the docker forms.

tools_io_docker becomes tools_io_container and the runtime spawner becomes
server_tools_container_runtime, both holding the client binary chosen at
parse time. A single parse_container_runtime() resolves every spec, so
adding another engine is one string in the table.

make_tools_io() now rejects the spawning forms. The spec also reaches it
from the x-tool-runtime header, which is client controlled, and only the
runtime that owns a container is allowed to create one: a tool call can
attach to a running container, nothing more.

* ./build/bin/llama-gen-docs

* server: simplify the tools runtime and drop the file copy step

A server_tools_runtime base with one virtual spec() replaces the
container runtime and the bare spec string that ssh needed next to it,
so server_tools is back to a single pointer and neither setup nor the
handler tests which of the two is set.

write_file used to spill its content into a temporary file on the host
and copy it in, because run_subprocess had no way to feed a child. It
now takes an optional stdin payload and creates the parent directory
and the file in a single round trip through a shell in the isolate.

That removes the upload virtual and both implementations: no more
container cp or scp, no second binary on the host, no sftp subsystem on
the target, no predictable temporary in a shared tmp, and none of the
content reaching an argv the remote shell re-parses. It also fixes
write_file over ssh, which never worked: scp speaks sftp and takes the
remote path literally, so quoting it kept the quotes in the file name.

Writing the payload before reading the output relies on the child
draining stdin as it goes, which holds for cat, its only user today.

* ./build/bin/llama-gen-docs

* server: harden the tools runtime against argv injection and a stdin stall

Validate the container id from x-tool-runtime and --tools-runtime the
same way the ssh target already is, so an id shaped like an option
(docker-container:--privileged) is rejected before it reaches the
engine's exec command line instead of running against a hardened
container. Feed the child's stdin after the watchdog is armed, so a
transport that stalls mid-write is terminated at the deadline rather
than blocking the request forever.

Cover both guards and fix the unknown-scheme test, which used ssh: as
its example and now names a real runtime.

* tests: exercise the tools runtime tests on podman as well as docker

Follow-up ggml-org#26507. The container runtime drives docker and podman
through one implementation, so parametrize the availability helper,
the container fixture and the attach test on the engine, and cover
both engine prefixes in the container id injection test. Each engine
skips on its own when it is not installed.

The spawn cleanup test stays docker only: it recovers the spawned id
from the container hostname, which docker sets to the short id and
podman rootless does not guarantee. Podman keeps its coverage through
the attach path.

* server: release the container handle before respawning

Follow-up ggml-org#26507. create() writes over the handle it is given, so a
respawn after the container died on its own leaked the pipes and the
process handle of the previous one.

* server: trim the tools runtime comments

* server: read tool output as raw bytes and harden the runtime on Windows

The stdout pipe is read with read() instead of fgets(), so a chunk
can hold any byte, including NUL, and still streams as soon as data
is available. Past the size cap the pipe keeps draining so the child
never blocks on a full pipe. Both pipe fds are forced to binary mode
on Windows, where the CRT defaults them to text mode and translates
line endings in both directions. Stdin is now always closed after
the feed: the child reads a deterministic EOF, and the Windows
docker and ssh clients stop outliving their command on a stdin pipe
that never closes.

The attach form of --tools-runtime has no lifecycle to own, so it
becomes a static target validated once at startup. This removes the
 subprocess that ran on every tool call and
serialized calls behind a mutex; a stopped container now surfaces
the engine's own error at exec time.

The cidfile path is passed as UTF-8, matching the encoding the
subprocess layer expects for the CreateProcessW command line, so
the spawn form works from a non-ASCII Windows profile.

The SIGPIPE note in server.cpp now names the tools runtime children
as well as the MCP ones.

* clean up comments

* less pollute global scope

* nits

* tests: name the container image after both engines

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
brittlewis12 pushed a commit to brittlewis12/llama.cpp that referenced this pull request Aug 17, 2026
* server: add initial tool isolation support (via docker)

* add docs

* adapt get_info

* py: fix type check

* cont

* separate tools_io_sandbox / tools_io_docker

* rename sandbox --> isolate

* x-tool-docker --> x-tool-runtime

---------

Co-authored-by: Pascal <admin@serveurperso.com>
brittlewis12 pushed a commit to brittlewis12/llama.cpp that referenced this pull request Aug 17, 2026
ggml-org#26774)

* server: add an ssh transport to the tools runtime

--tools-runtime ssh:<target> runs the built-in tools on a remote host,
where target is whatever ssh already resolves, a user@host or a config
alias, so no credentials live in llama.cpp.

Only build_argv and upload differ from the docker transport: the remote
shell re-parses the command line, so the argv travels through
shell_quote_join, and files go over scp with the same quoting on the
remote path. Authentication is key-based and the host key must already
be trusted, since the tools run without a console and any prompt would
hang them.

The target is validated before use. The spec can reach us from the
x-tool-runtime header, and a leading dash would turn it into an ssh
option, which is enough to run a command back on the host.

Nothing is created and nothing is reclaimed, so an ssh spec goes
straight to the tool call instead of through the container runtime.

Note that this is remoting rather than isolation: the tools can do
whatever the target account can do, and the isolation is whatever runs
them on the far side.

* server: support podman in the tools runtime

docker and podman expose the same run, exec, cp and inspect verbs with the
same argument order, so a single implementation drives both and the engine
is carried by the spec prefix: podman:<image> and podman-container:<id> sit
next to the docker forms.

tools_io_docker becomes tools_io_container and the runtime spawner becomes
server_tools_container_runtime, both holding the client binary chosen at
parse time. A single parse_container_runtime() resolves every spec, so
adding another engine is one string in the table.

make_tools_io() now rejects the spawning forms. The spec also reaches it
from the x-tool-runtime header, which is client controlled, and only the
runtime that owns a container is allowed to create one: a tool call can
attach to a running container, nothing more.

* ./build/bin/llama-gen-docs

* server: simplify the tools runtime and drop the file copy step

A server_tools_runtime base with one virtual spec() replaces the
container runtime and the bare spec string that ssh needed next to it,
so server_tools is back to a single pointer and neither setup nor the
handler tests which of the two is set.

write_file used to spill its content into a temporary file on the host
and copy it in, because run_subprocess had no way to feed a child. It
now takes an optional stdin payload and creates the parent directory
and the file in a single round trip through a shell in the isolate.

That removes the upload virtual and both implementations: no more
container cp or scp, no second binary on the host, no sftp subsystem on
the target, no predictable temporary in a shared tmp, and none of the
content reaching an argv the remote shell re-parses. It also fixes
write_file over ssh, which never worked: scp speaks sftp and takes the
remote path literally, so quoting it kept the quotes in the file name.

Writing the payload before reading the output relies on the child
draining stdin as it goes, which holds for cat, its only user today.

* ./build/bin/llama-gen-docs

* server: harden the tools runtime against argv injection and a stdin stall

Validate the container id from x-tool-runtime and --tools-runtime the
same way the ssh target already is, so an id shaped like an option
(docker-container:--privileged) is rejected before it reaches the
engine's exec command line instead of running against a hardened
container. Feed the child's stdin after the watchdog is armed, so a
transport that stalls mid-write is terminated at the deadline rather
than blocking the request forever.

Cover both guards and fix the unknown-scheme test, which used ssh: as
its example and now names a real runtime.

* tests: exercise the tools runtime tests on podman as well as docker

Follow-up ggml-org#26507. The container runtime drives docker and podman
through one implementation, so parametrize the availability helper,
the container fixture and the attach test on the engine, and cover
both engine prefixes in the container id injection test. Each engine
skips on its own when it is not installed.

The spawn cleanup test stays docker only: it recovers the spawned id
from the container hostname, which docker sets to the short id and
podman rootless does not guarantee. Podman keeps its coverage through
the attach path.

* server: release the container handle before respawning

Follow-up ggml-org#26507. create() writes over the handle it is given, so a
respawn after the container died on its own leaked the pipes and the
process handle of the previous one.

* server: trim the tools runtime comments

* server: read tool output as raw bytes and harden the runtime on Windows

The stdout pipe is read with read() instead of fgets(), so a chunk
can hold any byte, including NUL, and still streams as soon as data
is available. Past the size cap the pipe keeps draining so the child
never blocks on a full pipe. Both pipe fds are forced to binary mode
on Windows, where the CRT defaults them to text mode and translates
line endings in both directions. Stdin is now always closed after
the feed: the child reads a deterministic EOF, and the Windows
docker and ssh clients stop outliving their command on a stdin pipe
that never closes.

The attach form of --tools-runtime has no lifecycle to own, so it
becomes a static target validated once at startup. This removes the
 subprocess that ran on every tool call and
serialized calls behind a mutex; a stopped container now surfaces
the engine's own error at exec time.

The cidfile path is passed as UTF-8, matching the encoding the
subprocess layer expects for the CreateProcessW command line, so
the spawn form works from a non-ASCII Windows profile.

The SIGPIPE note in server.cpp now names the tools runtime children
as well as the MCP ones.

* clean up comments

* less pollute global scope

* nits

* tests: name the container image after both engines

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants