Skip to content

feat(containers): add NativeBackend for bare-metal worker deployment#1691

Closed
hognek wants to merge 1 commit into
jaylfc:devfrom
hognek:feat/native-bare-metal-backend
Closed

feat(containers): add NativeBackend for bare-metal worker deployment#1691
hognek wants to merge 1 commit into
jaylfc:devfrom
hognek:feat/native-bare-metal-backend

Conversation

@hognek

@hognek hognek commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a bare-metal deployment option for TinyAgentOS workers — no containers, no Incus, no Docker. The worker agent runs directly on the host, avoiding container overhead on constrained nodes (e.g., CPU-only Qwen3-Embedding-8B on a low-power node).

Fixes #892.

What's new

  • tinyagentos/containers/native.pyNativeBackend implements the full ContainerBackend ABC using systemd service units and direct subprocess calls.
    • create_container() writes a systemd unit, exec_in_container() runs commands directly on the host, push_file() copies files natively, etc.
    • Gracefully falls back when systemd is unavailable (e.g., CI, container-in-container) with sensible no-op defaults.
  • containers/backend.py — registered "native" in configure_container_runtime(). Native is config-only — never auto-detected (every host is bare-metal-capable, so auto-detect would shadow LXC/Docker).
  • containers/__init__.py — exported NativeBackend.
  • routes/settings.py — added "native" to the allowed runtime list and backend-switching logic.

Tests

  • test_container_native.py — 24 new tests covering:
    • E2E fallback path (no systemd): create, list, exec, push, start/stop/destroy, proxy, snapshots, quota, env
    • Systemd-mocked path: unit file creation with memory/cpu/env, destroy, rename, set_env (add + replace), logs
    • Helper functions: _service_unit_path, _has_systemd
  • test_container_runtime_config.py — 2 new tests: config override to native, non-auto-detection guarantee
  • All existing container tests (83), worker tests (101), and detection tests (49) pass — zero regressions.

Usage

Set in config.yaml:

container_runtime: native

Or via the settings API:

PUT /api/settings/container-runtime  {"runtime": "native"}

Add a bare-metal container backend that runs agent workloads directly
on the host without container overhead.  Selected when
container_runtime is explicitly set to 'native' in taOS config.

Changes:
- New NativeBackend in containers/native.py — implements the full
  ContainerBackend API using systemd service units and direct
  subprocess calls.  Falls back gracefully when systemd is absent.
- Registered in configure_container_runtime() as a config-only
  runtime (never auto-detected — bare metal is always available).
- Exported from containers/__init__.py.
- Added 'native' to the allowed runtime list in the settings API.
- 24 new tests in test_container_native.py covering creation,
  lifecycle, env management, snapshots, and fallback paths.
- 2 new tests in test_container_runtime_config.py for native
  config override and non-auto-detection guarantee.

Fixes jaylfc#892
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@hognek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f606050-97f6-4075-9081-9a108f0b82af

📥 Commits

Reviewing files that changed from the base of the PR and between 2523191 and 9b1a5ee.

📒 Files selected for processing (6)
  • tests/test_container_native.py
  • tests/test_container_runtime_config.py
  • tinyagentos/containers/__init__.py
  • tinyagentos/containers/backend.py
  • tinyagentos/containers/native.py
  • tinyagentos/routes/settings.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hognek

hognek commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@gitar-bot

gitar-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

You are using the Gitar free plan. Upgrade to unlock code review, CI analysis, auto-apply, custom automations, and more.

Gitar

@hognek
hognek marked this pull request as ready for review July 7, 2026 07:35
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

name = unit[:-len(".service")] if unit.endswith(".service") else unit
if not name.startswith(prefix):
continue
status = parts[3] # ACTIVE: active, inactive, failed, etc.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Wrong column used for ACTIVE state.

systemctl list-units --no-legend emits columns in the order UNIT LOAD ACTIVE SUB JOB DESCRIPTION. parts[3] is the SUB column (sub-state like running, dead, exited), not the ACTIVE column. The intent here is the top-level ACTIVE state (active, inactive, failed), which lives at parts[2].

Effect: a service that is active (running) (most normal agents) will still map to Running because both ACTIVE and SUB are running/active — but a service in any other SUB state while ACTIVE (e.g. active (exited) for a Type=oneshot, active (waiting) for a Type=notify, active (start-pre) during a delayed start) will be misreported as the SUB value capitalized, and failed/inactive agents with non-trivial SUB will also be misclassified.

Suggested change
status = parts[3] # ACTIVE: active, inactive, failed, etc.
status = parts[2] # ACTIVE: active, inactive, failed, etc.

os.close(self._master_fd)
except OSError:
pass
self._proc.wait(timeout=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Blocking subprocess.Popen.wait(timeout=5) inside an async context.

_NativePtyHandle.close is reached from the async event loop, but proc.wait(timeout=5) is a synchronous blocking call. If the child ignores SIGTERM (or a 5-second timeout is too short for graceful shutdown), the loop stalls for up to 5 seconds per PTY close. With multiple concurrent PTYs this serialises and can wedge interactive shells.

Use the asyncio subprocess variant (await loop.run_in_executor(None, proc.wait)) or wait with asyncio.wait_for on proc.wait() after switching to asyncio.create_subprocess_exec.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


[Service]
Type=simple
ExecStart=/bin/true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: The generated unit is inert — start_container will report success but does nothing.

ExecStart=/bin/true plus Restart=no means systemctl start <unit>.service returns success immediately and the process exits 0 within milliseconds. From the caller's point of view create_container + start_container both returned success: True, so deployment code that polls these to wait for "running" will believe the agent is up when nothing is actually running.

The docstring acknowledges this is a "stub", but the public API surface does not. Consider either:

  • leaving success: True only when the unit file is written AND ExecStart is replaced, returning success: False (with a clear note) when the unit is still the /bin/true stub; or
  • adding a wait_for_exec parameter / a reload_payload(name, exec_start, ...) method that callers must invoke before start_container.

At minimum, the start_container success output should warn when ExecStart=/bin/true, so misconfigured deploys fail loudly.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

# rename_container
# ------------------------------------------------------------------

async def rename_container(self, old_name: str, new_name: str) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: rename_container does not stop a running service before renaming on disk.

The ABC docstring at backend.py:153-154 explicitly says rename_container is for a stopped container. The LXC backend relies on incus rename to enforce that. This implementation renames the unit file on disk regardless of state; if the service is active, systemd keeps the old unit name live in memory, the new file is loaded only after the next daemon-reload (which is run), and any process still bound to the old name via /run/systemd ends up in a half-renamed state. There's no systemctl stop first and no check of is-active.

Either reject the rename when the unit is active (return success=False, output='unit is active; stop first') or stop+disable, rename, daemon-reload, and document the behaviour.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"""

try:
_SYSTEMD_DIR.mkdir(parents=True, exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: _SYSTEMD_DIR.mkdir(parents=True, exist_ok=True) and unit_path.write_text(...) are attempted unconditionally under the systemd branch, with no privilege check.

On any systemd-enabled host where taOS is not running as root (which is the common case — systemd-managed services typically run as a dedicated user), this will raise PermissionError: [Errno 13] Permission denied: '/etc/systemd/system'. The except branch returns {"success": False, "error": "...Permission denied..."}, which is fine, but:

  1. There is no upfront detection (e.g., os.access(_SYSTEMD_DIR, os.W_OK)) so the failure surfaces only after the call.
  2. The PR claims to support a bare-metal workflow, but doesn't say anything about needing root or a dedicated unit directory; without a sudo/root setup it always fails on first run. The README/PR description should call this out.

Consider supporting a configurable unit directory via env var or config (TAOS_SYSTEMD_DIR=~/.local/share/taos/systemd for non-root), or returning a clearer note field that tells the operator exactly which privilege is missing.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

# exec_in_container
# ------------------------------------------------------------------

async def exec_in_container(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: name is decorative on the native backend — security boundary must be enforced by the caller.

exec_in_container, push_file, and spawn_pty all ignore name and operate directly on the host filesystem / process tree / network with full root-style privileges (modulo the actual uid of the taOS process). Any caller that has per-name authorisation checks (the obvious mental model from the LXC/Docker backends, where name IS the isolation boundary) silently gets nothing here.

This is an intentional design choice given the bare-metal intent, but it MUST be called out at the class docstring level (not just the per-method docstrings) so anyone plugging a new caller into the ContainerBackend interface understands that selecting the native backend changes the security model. Something like a # SECURITY: block at the top of NativeBackend stating: "Selecting this backend removes the per-name isolation that LXC/Docker provide. All operations execute with the taOS process's full privileges. Callers must treat name as a label, not a security boundary, and must authenticate/authorise separately."

Also consider raising (not silently ignoring) if name contains path-traversal sequences like ../ for push_file/remote_path — the LXC backend implicitly protects against this because the container rootfs is sandboxed.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

))
return results

async def _list_containers_fallback(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: _list_containers_fallback is effectively dead code.

It's only invoked when _has_systemd() returns False (i.e. systemctl isn't on PATH). On any such host /etc/systemd/system typically doesn't exist either, so the first check at line 151 short-circuits and returns []. The only case this path is useful is a CI runner or container-in-container that has /etc/systemd/system populated as a mount but no systemctl binary — extremely rare.

Either drop it (and have list_containers return [] early on no-systemd) or document the niche case it covers in the docstring.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

stderr=asyncio.subprocess.STDOUT,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return proc.returncode or 0, stdout.decode() if stdout else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: proc.returncode or 0 collapses negative return codes (kill-by-signal) to 0.

When a child is terminated by SIGKILL/SIGTERM, asyncio.subprocess.Process.returncode is set to -SIGTERM / -SIGKILL (negative). x or 0 evaluates truthy for any non-zero integer including negatives, so -15 or 0 == -15 — fine here. But this is fragile to refactoring: a future contributor switching to returncode or 0 style elsewhere may lose signal info. Use proc.returncode if proc.returncode is not None else 0 (also handles the None case during timeout).

Also, communicate() raises TimeoutError on timeout — _run doesn't catch it, so a hung command raises into the caller without cleanup of the subprocess. Consider try/except (TimeoutError, asyncio.TimeoutError) and proc.kill() + await proc.wait() in a finally.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 8 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 2
WARNING 4
SUGGESTION 2

The new NativeBackend (config-only bare-metal container backend using systemd service units) is well-structured and the test coverage is solid, but a few issues should be addressed before merge: a real column-parsing bug in list_containers (parts[3] is SUB, not ACTIVE), a blocking subprocess.wait() inside an async PTY close that can stall the event loop, an inert unit-file template (ExecStart=/bin/true, Restart=no) that makes start_container a silent no-op, and a missing upfront privilege check that means non-root hosts fail with a generic PermissionError after the call rather than a clear upfront error.

The name parameter being decorative across exec_in_container / push_file / spawn_pty is by design (bare metal has no isolation), but it MUST be called out prominently at the class level so callers do not assume per-name auth.

Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/containers/native.py 127 parts[3] is the SUB column of systemctl list-units, not ACTIVE. Should be parts[2]. Mis-classifies agents in active (exited)/active (waiting) states.
tinyagentos/containers/native.py 64 Blocking subprocess.Popen.wait(timeout=5) inside _NativePtyHandle.close — stalls the asyncio loop for up to 5s per PTY close.

WARNING

File Line Issue
tinyagentos/containers/native.py 231 Generated unit has ExecStart=/bin/true + Restart=no. start_container reports success but the unit exits immediately — callers cannot tell the agent isn't actually running.
tinyagentos/containers/native.py 356 rename_container renames the unit file on disk without stopping a running service, breaking the ABC contract that says rename requires a stopped container.
tinyagentos/containers/native.py 239 _SYSTEMD_DIR.mkdir(...) runs unconditionally on non-root hosts and surfaces as a generic PermissionError; no upfront os.access check or documented root requirement.
tinyagentos/containers/native.py 256 name is decorative on native — exec_in_container/push_file/spawn_pty ignore it and run with full host privileges. Class-level security warning missing; LXC/Docker callers relying on per-name auth get nothing here.

SUGGESTION

File Line Issue
tinyagentos/containers/native.py 146 _list_containers_fallback is effectively dead — when _has_systemd() is False, _SYSTEMD_DIR typically doesn't exist either.
tinyagentos/containers/native.py 75 proc.returncode or 0 is fragile (collapses negatives for some expressions); _run also doesn't proc.kill() on TimeoutError.
Files Reviewed (6 files)
  • tests/test_container_native.py - 0 issues
  • tests/test_container_runtime_config.py - 0 issues
  • tinyagentos/containers/__init__.py - 0 issues
  • tinyagentos/containers/backend.py - 0 issues
  • tinyagentos/containers/native.py - 8 issues
  • tinyagentos/routes/settings.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by minimax-m3 · Input: 48.6K · Output: 8.1K · Cached: 352.4K

@jaylfc

jaylfc commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Did a deep pass on this. As dormant opt-in code it is safe (no auto-detect, existing backends untouched), but a couple of things before it should be selectable:

  1. Host-mutation surface: once native is chosen, the agent deployer runs apt/pip installs and pushes files directly onto the controller host, and push_file writes to arbitrary absolute paths with no traversal guard. Please add a path-traversal guard on push_file/remote_path and an upfront root/writable-unit-dir check, plus a class-level SECURITY note so callers do not carry the LXC/Docker 'name is the boundary' assumption (name is decorative on exec/push/spawn_pty here).
  2. Supervision is a no-op: ExecStart=/bin/true with Restart=no means create/start return success while nothing actually runs the agent. Either wire the unit to supervise a real payload, or return success:False while it is still a stub, so 'deployed and running' is not reported falsely.
  3. Two real correctness bugs Kilo flagged: list_containers reads the wrong systemd field (SUB instead of ACTIVE) so it misreports state, and the async PTY close does a blocking proc.wait(timeout=5) that stalls the event loop.

Also worth confirming scope: issue #892 describes a cluster worker capability contract (register/advertise/drain/apply_update/health-check for model-serving backends), whereas this implements a ContainerBackend ABC used by the agent deployer. Related but a different abstraction, so I would hold off marking #892 fixed. Happy to merge once the isolation posture and supervision are sorted.

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Makes sense — will add the path-traversal guard + writable-dir precheck + the SECURITY note that name is decorative here, and either wire real supervision or return success:False while it is a stub rather than report deployed-and-running. Will also fix the SUB-vs-ACTIVE state read and the blocking proc.wait in the async PTY close. And agreed on scope: leaving #892 open — this is the ContainerBackend ABC, not the cluster-worker capability contract.

@hognek

hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #1713, which contains this same NativeBackend implementation PLUS your requested review fixes [path-traversal guard on push_file/remote_path + writable-dir precheck + SECURITY note that name is decorative; real supervision or success:False for the stub; SUB->ACTIVE state read; non-blocking PTY close]. Please review #1713 instead — closing this to avoid two PRs for the same work. Scope note still stands: this is the ContainerBackend ABC, not the #892 cluster-worker capability contract, so #892 stays open.

@hognek hognek closed this Jul 7, 2026
@kilo-code-bot kilo-code-bot Bot mentioned this pull request Jul 7, 2026
@hognek
hognek deleted the feat/native-bare-metal-backend branch July 17, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants