feat(worker): add the terminal_session built-in tool #349

Merged
rwachters merged 4 commits from feat/worker-terminal-session into master 2026-10-21 19:00:41 +00:00
rwachters commented 2026-10-21 18:58:57 +00:00 (Migrated from github.com)

Summary

Adds a new built-in worker tool, terminal_session, that runs a long-lived interactive PTY session inside the worker workspace so the model can drive a shell, an ssh session or another line-oriented program across several tool calls instead of paying for a fresh process each time. Sessions are addressed by the id returned from start, read with a per-session cursor so each poll returns only new output, and bounded in memory, count, lifetime and blocking time by a new worker.terminalSessions configuration group. The change also prepares the deployed worker image for pty4j and relaxes one assertion in the setuid privilege-drop helper, because pty4j's child is already a session leader when the helper runs.

Changes

  • Added the terminal_session tool with start, send, poll and close, plus the PTY runtime behind it: a byte-bounded output ring buffer, an output sanitizer, a child-environment builder, a process factory, the session itself and a manager that reaps idle, over-lifetime and closed sessions.
  • Added the worker.terminalSessions configuration group with defaults, ranges and cross-field validation, documented in both dist templates, the bundled default config and the dev config sample.
  • Published the tool definition in BuiltInToolCatalog so the server seeds it per worker, and marked it dangerous in the app's built-in tool settings.
  • Wired the manager into Koin and closed live sessions from a JVM shutdown hook, since SIGTERM does not run the normal stopKoin() path.
  • Added the pty4j dependency and prepared the worker image: a dedicated native-extraction directory outside the isolated account's writable tree, plus -Dpty4j.tmpdir and native-access JVM flags.
  • Changed deploy/worker/run-as-agent.c to accept an already-session-leader child instead of failing with EPERM on setpgid(0,0).
  • Scoped the TUI detection heuristic to screen-control sequences so a plain interactive shell no longer reports a full-screen UI, while alternate-screen detection is unchanged.
  • Made poll on a closed session a success that drains the remaining buffered output and then returns an empty CLOSED snapshot, rather than an error that kept yielding more data.
  • Required maxBytes of at least 4 so a chunk can always hold one maximum-width UTF-8 code point and a tombstone cannot stall.

Implementation notes

  • There is deliberately no worker-side enable switch. The capability is controlled by server-side tool enablement and by whether the image contains it; a call that reaches the worker executes. This keeps the worker from owning a second, redundant policy.
  • Sessions run as the same account as run_command children. Under command isolation that means the isolated account with PR_SET_NO_NEW_PRIVS, so sudo, su and passwd cannot elevate inside a session, and secrets are not supported in v1: an interactive ssh must use keys or an already-authenticated agent.
  • The read cursor advances only over characters actually returned, so a slow reader gets an explicit dropped-byte notice instead of silently duplicated output.
  • QUIET in the tool result means the process is alive but has been silent for the quiet window, which usually means it is waiting for input; it does not mean the process exited. Full-screen TUI programs are out of scope and the tool description says so.
  • Memory is bounded twice: at most maxSessions live sessions and at most maxClosedSessions tombstones of maxOutputBytesPerSession each, about 8 MiB with the defaults. reaperIntervalSeconds is validated to be shorter than defaultIdleTimeoutSeconds so a session cannot outlive its TTL by more than one sweep.
  • Shutdown is deliberately dual: Koin's onClose covers tests and stopKoin(), and the JVM shutdown hook is the only path that reaches live PTYs on SIGTERM. The manager close is idempotent, so either may win.
  • The helper change is the narrowest possible: setpgid(0,0) is still called whenever the helper is not already its own process-group leader, so the existing run_command isolation path keeps its previous behaviour and only the pty4j spawn shape is newly accepted.

Validation

  • ./gradlew :worker:classes, :worker:testClasses, :worker:test, :common:desktopTestClasses, :server:testClasses, :app:desktopTestClasses all pass; the terminal suites cover the ring buffer, sanitizer, environment builder, manager (slot accounting, id collisions, reaping, retention, cancellation, closed-session draining) and tool argument handling.
  • The worker image was built and a container started; pty4j loaded its native library from the configured temp directory, and an isolated PTY child ran as agent with no run-as-agent: setpgid failure.
  • Manual client-app checks passed: state persisted across calls (cd /tmp then pwd returned /tmp), close returned CLOSED, output truncation and cursor continuation behaved, and the TUI hint no longer appears for a plain shell.

Residual unverified items

  • docker stop / SIGTERM reaping of live PTYs was not observed end to end; the shutdown-hook path is reviewed but not watched.
  • The gated Pty4jTerminalSessionIntegrationTest was not run as a JUnit test in an image; the isolation behaviour was verified manually through the client instead.
  • A real-PTY closed-session drain involving a 4-byte code point was not exercised; that path is covered by fixtures only.
  • Operators who override JAVA_OPTS must keep -Dpty4j.tmpdir and --enable-native-access=ALL-UNNAMED, otherwise pty4j extracts to a location the worker cannot execute from.

How to verify the isolation gate manually

  1. Build the worker image from deploy/worker/Dockerfile, which installs the setuid helper at /usr/local/bin/run-as-agent and the agent account with its home at /home/agent.
  2. Run the gate with CHATBOT_TERMINAL_ISOLATION_GATE=true ./gradlew :worker:test --tests '*Pty4jTerminalSessionIntegrationTest*'; a missing gate variable or helper shows up as a skip rather than a pass, so read the test report.
  3. Start a session, send id -un, and confirm agent with no run-as-agent: setpgid in the output.
  4. Start a session, docker stop the container, and confirm the worker log shows the session closed and no orphaned child survives.
## Summary Adds a new built-in worker tool, `terminal_session`, that runs a long-lived interactive PTY session inside the worker workspace so the model can drive a shell, an `ssh` session or another line-oriented program across several tool calls instead of paying for a fresh process each time. Sessions are addressed by the id returned from `start`, read with a per-session cursor so each `poll` returns only new output, and bounded in memory, count, lifetime and blocking time by a new `worker.terminalSessions` configuration group. The change also prepares the deployed worker image for pty4j and relaxes one assertion in the setuid privilege-drop helper, because pty4j's child is already a session leader when the helper runs. ## Changes - Added the `terminal_session` tool with `start`, `send`, `poll` and `close`, plus the PTY runtime behind it: a byte-bounded output ring buffer, an output sanitizer, a child-environment builder, a process factory, the session itself and a manager that reaps idle, over-lifetime and closed sessions. - Added the `worker.terminalSessions` configuration group with defaults, ranges and cross-field validation, documented in both dist templates, the bundled default config and the dev config sample. - Published the tool definition in `BuiltInToolCatalog` so the server seeds it per worker, and marked it dangerous in the app's built-in tool settings. - Wired the manager into Koin and closed live sessions from a JVM shutdown hook, since SIGTERM does not run the normal `stopKoin()` path. - Added the pty4j dependency and prepared the worker image: a dedicated native-extraction directory outside the isolated account's writable tree, plus `-Dpty4j.tmpdir` and native-access JVM flags. - Changed `deploy/worker/run-as-agent.c` to accept an already-session-leader child instead of failing with `EPERM` on `setpgid(0,0)`. - Scoped the TUI detection heuristic to screen-control sequences so a plain interactive shell no longer reports a full-screen UI, while alternate-screen detection is unchanged. - Made `poll` on a closed session a success that drains the remaining buffered output and then returns an empty `CLOSED` snapshot, rather than an error that kept yielding more data. - Required `maxBytes` of at least 4 so a chunk can always hold one maximum-width UTF-8 code point and a tombstone cannot stall. ## Implementation notes - There is deliberately no worker-side enable switch. The capability is controlled by server-side tool enablement and by whether the image contains it; a call that reaches the worker executes. This keeps the worker from owning a second, redundant policy. - Sessions run as the same account as `run_command` children. Under command isolation that means the isolated account with `PR_SET_NO_NEW_PRIVS`, so `sudo`, `su` and `passwd` cannot elevate inside a session, and secrets are not supported in v1: an interactive `ssh` must use keys or an already-authenticated agent. - The read cursor advances only over characters actually returned, so a slow reader gets an explicit dropped-byte notice instead of silently duplicated output. - `QUIET` in the tool result means the process is alive but has been silent for the quiet window, which usually means it is waiting for input; it does not mean the process exited. Full-screen TUI programs are out of scope and the tool description says so. - Memory is bounded twice: at most `maxSessions` live sessions and at most `maxClosedSessions` tombstones of `maxOutputBytesPerSession` each, about 8 MiB with the defaults. `reaperIntervalSeconds` is validated to be shorter than `defaultIdleTimeoutSeconds` so a session cannot outlive its TTL by more than one sweep. - Shutdown is deliberately dual: Koin's `onClose` covers tests and `stopKoin()`, and the JVM shutdown hook is the only path that reaches live PTYs on SIGTERM. The manager close is idempotent, so either may win. - The helper change is the narrowest possible: `setpgid(0,0)` is still called whenever the helper is not already its own process-group leader, so the existing `run_command` isolation path keeps its previous behaviour and only the pty4j spawn shape is newly accepted. ## Validation - `./gradlew :worker:classes`, `:worker:testClasses`, `:worker:test`, `:common:desktopTestClasses`, `:server:testClasses`, `:app:desktopTestClasses` all pass; the terminal suites cover the ring buffer, sanitizer, environment builder, manager (slot accounting, id collisions, reaping, retention, cancellation, closed-session draining) and tool argument handling. - The worker image was built and a container started; pty4j loaded its native library from the configured temp directory, and an isolated PTY child ran as `agent` with no `run-as-agent: setpgid` failure. - Manual client-app checks passed: state persisted across calls (`cd /tmp` then `pwd` returned `/tmp`), `close` returned `CLOSED`, output truncation and cursor continuation behaved, and the TUI hint no longer appears for a plain shell. ## Residual unverified items - `docker stop` / SIGTERM reaping of live PTYs was not observed end to end; the shutdown-hook path is reviewed but not watched. - The gated `Pty4jTerminalSessionIntegrationTest` was not run as a JUnit test in an image; the isolation behaviour was verified manually through the client instead. - A real-PTY closed-session drain involving a 4-byte code point was not exercised; that path is covered by fixtures only. - Operators who override `JAVA_OPTS` must keep `-Dpty4j.tmpdir` and `--enable-native-access=ALL-UNNAMED`, otherwise pty4j extracts to a location the worker cannot execute from. ## How to verify the isolation gate manually 1. Build the worker image from `deploy/worker/Dockerfile`, which installs the setuid helper at `/usr/local/bin/run-as-agent` and the `agent` account with its home at `/home/agent`. 2. Run the gate with `CHATBOT_TERMINAL_ISOLATION_GATE=true ./gradlew :worker:test --tests '*Pty4jTerminalSessionIntegrationTest*'`; a missing gate variable or helper shows up as a skip rather than a pass, so read the test report. 3. Start a session, send `id -un`, and confirm `agent` with no `run-as-agent: setpgid` in the output. 4. Start a session, `docker stop` the container, and confirm the worker log shows the session closed and no orphaned child survives.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Torvian.eu/chatbot!349
No description provided.