Compare commits
20 Commits
8ca1ede4ec
...
4fdf354b4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fdf354b4f | |||
| 5a2011c48f | |||
| 19ebcd52a1 | |||
| 2c061d9cd9 | |||
| cceb300d58 | |||
| b63927368a | |||
| 4319b4ef3b | |||
| 71005d56e2 | |||
| 96b0c3f1fa | |||
| 3087a9aa8b | |||
| e43f75dd1b | |||
| 4ad1ff3898 | |||
| a3d9ac9605 | |||
| 70c9f7254c | |||
| b9108339e7 | |||
| e5b5dd16f1 | |||
| cf76d1a245 | |||
| 717a9126e1 | |||
| 8830306101 | |||
| 1c242b0ad9 |
@@ -105,6 +105,10 @@ class BottleMetadata:
|
|||||||
# written before chunk 3 (resume / inspect should fall back to
|
# written before chunk 3 (resume / inspect should fall back to
|
||||||
# deriving from identity in that case).
|
# deriving from identity in that case).
|
||||||
compose_project: str = ""
|
compose_project: str = ""
|
||||||
|
# PRD 0040: backend name ("docker" or "smolmachines"). Empty string
|
||||||
|
# for state dirs written before PRD 0040; callers default to "docker"
|
||||||
|
# for backward compatibility.
|
||||||
|
backend: str = ""
|
||||||
|
|
||||||
|
|
||||||
def metadata_path(identity: str) -> Path:
|
def metadata_path(identity: str) -> Path:
|
||||||
@@ -138,6 +142,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
|||||||
copy_cwd=bool(raw.get("copy_cwd", False)),
|
copy_cwd=bool(raw.get("copy_cwd", False)),
|
||||||
started_at=str(raw.get("started_at", "")),
|
started_at=str(raw.get("started_at", "")),
|
||||||
compose_project=str(raw.get("compose_project", "")),
|
compose_project=str(raw.get("compose_project", "")),
|
||||||
|
backend=str(raw.get("backend", "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ def resolve_plan(
|
|||||||
copy_cwd=spec.copy_cwd,
|
copy_cwd=spec.copy_cwd,
|
||||||
started_at=datetime.now(timezone.utc).isoformat(),
|
started_at=datetime.now(timezone.utc).isoformat(),
|
||||||
compose_project=f"bot-bottle-{slug}",
|
compose_project=f"bot-bottle-{slug}",
|
||||||
|
backend="docker",
|
||||||
))
|
))
|
||||||
# Clear any leftover preserve marker from a prior capability-block
|
# Clear any leftover preserve marker from a prior capability-block
|
||||||
# so this fresh launch can be cleaned up at session-end unless
|
# so this fresh launch can be cleaned up at session-end unless
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ...backend.docker.bottle_state import (
|
|||||||
write_metadata,
|
write_metadata,
|
||||||
)
|
)
|
||||||
from ...egress import Egress
|
from ...egress import Egress
|
||||||
|
from ...env import resolve_env
|
||||||
from ...git_gate import GitGate
|
from ...git_gate import GitGate
|
||||||
from ...pipelock import PipelockProxy
|
from ...pipelock import PipelockProxy
|
||||||
from ...supervise import Supervise
|
from ...supervise import Supervise
|
||||||
@@ -70,25 +71,24 @@ def resolve_plan(
|
|||||||
cwd=spec.user_cwd if spec.copy_cwd else "",
|
cwd=spec.user_cwd if spec.copy_cwd else "",
|
||||||
copy_cwd=spec.copy_cwd,
|
copy_cwd=spec.copy_cwd,
|
||||||
started_at=datetime.now(timezone.utc).isoformat(),
|
started_at=datetime.now(timezone.utc).isoformat(),
|
||||||
# No compose project for smolmachines bottles; chunk 4
|
|
||||||
# will give dashboard discovery a backend-specific path.
|
|
||||||
compose_project="",
|
compose_project="",
|
||||||
|
backend="smolmachines",
|
||||||
))
|
))
|
||||||
|
|
||||||
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
||||||
|
|
||||||
# Agent's env: the prepare-time view doesn't yet know the
|
# Agent's env: resolve through resolve_env() so ?prompt entries
|
||||||
# host loopback ports the bundle's daemons get published on
|
# are prompted and ${HOST_VAR} entries are interpolated — matching
|
||||||
# (those come from docker AFTER `docker run` returns), so
|
# the Docker backend's contract. Forwarded (secret/interpolated)
|
||||||
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are
|
# values still reach the guest as -e K=V smolvm flags because
|
||||||
# populated in launch.py and stamped onto guest_env there.
|
# smolvm 0.8.0 has no env-file or stdin injection path; this is
|
||||||
# What we set here is the part that doesn't depend on
|
# the known argv-exposure gap documented in PRD 0038.
|
||||||
# bundle bringup — bottle.env literals, the empty-NO_PROXY
|
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
|
||||||
# safe default, and the TLS trust env trio
|
# in launch.py after bundle bringup.
|
||||||
# (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE)
|
resolved = resolve_env(manifest, spec.agent_name)
|
||||||
# pointing at Debian's update-ca-certificates output bundle.
|
|
||||||
guest_env: dict[str, str] = {
|
guest_env: dict[str, str] = {
|
||||||
**bottle.env,
|
**resolved.literals,
|
||||||
|
**resolved.forwarded,
|
||||||
"NO_PROXY": "localhost,127.0.0.1",
|
"NO_PROXY": "localhost,127.0.0.1",
|
||||||
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
|||||||
+24
-14
@@ -175,6 +175,13 @@ def approve(
|
|||||||
qp.proposal.bottle_slug, file_to_apply,
|
qp.proposal.bottle_slug, file_to_apply,
|
||||||
)
|
)
|
||||||
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||||
|
_meta = read_metadata(qp.proposal.bottle_slug)
|
||||||
|
if _meta is not None and not _meta.compose_project:
|
||||||
|
raise CapabilityApplyError(
|
||||||
|
"capability-block remediation is not supported for smolmachines "
|
||||||
|
"bottles. Reject this proposal or handle the capability change "
|
||||||
|
"manually, then restart the bottle."
|
||||||
|
)
|
||||||
diff_before, diff_after = apply_capability_change(
|
diff_before, diff_after = apply_capability_change(
|
||||||
qp.proposal.bottle_slug, file_to_apply,
|
qp.proposal.bottle_slug, file_to_apply,
|
||||||
)
|
)
|
||||||
@@ -640,23 +647,19 @@ def _bottle_for_slug(
|
|||||||
) -> tuple["object", str]:
|
) -> tuple["object", str]:
|
||||||
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach.
|
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach.
|
||||||
If the slug is in `bottles` (dashboard-owned), return the stored
|
If the slug is in `bottles` (dashboard-owned), return the stored
|
||||||
handle directly. Otherwise synthesize a `DockerBottle` from the
|
handle directly. Otherwise synthesize a bottle from the persisted
|
||||||
container name `bot-bottle-<slug>`. For synthesized bottles
|
metadata. The backend field in metadata (PRD 0040) selects Docker
|
||||||
the prompt-file path comes from the manifest's agent if we can
|
or smolmachines; unknown or missing metadata defaults to Docker.
|
||||||
resolve it via metadata.json + the loaded manifest; otherwise
|
|
||||||
the re-attach runs without `--append-system-prompt-file`.
|
|
||||||
|
|
||||||
Returns the empty string for prompt_path_hint when we omit the
|
Returns the empty string for prompt_path_hint when we omit the
|
||||||
flag — the caller passes None to DockerBottle in that case."""
|
flag — the caller passes None to DockerBottle in that case."""
|
||||||
from ..backend.docker.bottle import DockerBottle
|
from ..backend.docker.bottle import DockerBottle
|
||||||
from ..backend.docker.bottle_state import read_metadata
|
from ..backend.docker.bottle_state import read_metadata
|
||||||
|
from ..backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
if slug in bottles:
|
if slug in bottles:
|
||||||
_cm, bottle, _identity = bottles[slug]
|
_cm, bottle, _identity = bottles[slug]
|
||||||
return bottle, ""
|
return bottle, ""
|
||||||
# The container hosting the agent's agent process is named
|
instance_name = f"bot-bottle-{slug}"
|
||||||
# `bot-bottle-<slug>` — set by the compose renderer
|
|
||||||
# (no service suffix on the agent service, by design).
|
|
||||||
container_name = f"bot-bottle-{slug}"
|
|
||||||
prompt_path: str | None = None
|
prompt_path: str | None = None
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
if metadata is not None and manifest is not None:
|
if metadata is not None and manifest is not None:
|
||||||
@@ -666,11 +669,18 @@ def _bottle_for_slug(
|
|||||||
"BOT_BOTTLE_CONTAINER_HOME", "/home/node",
|
"BOT_BOTTLE_CONTAINER_HOME", "/home/node",
|
||||||
)
|
)
|
||||||
prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
|
||||||
synth = DockerBottle(
|
backend = metadata.backend if metadata is not None else ""
|
||||||
container=container_name,
|
if backend == "smolmachines":
|
||||||
teardown=lambda: None,
|
synth: object = SmolmachinesBottle(
|
||||||
prompt_path_in_container=prompt_path,
|
instance_name,
|
||||||
)
|
prompt_path=prompt_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synth = DockerBottle(
|
||||||
|
container=instance_name,
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=prompt_path,
|
||||||
|
)
|
||||||
return synth, (prompt_path or "")
|
return synth, (prompt_path or "")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ def cmd_resume(argv: list[str]) -> int:
|
|||||||
user_cwd=metadata.cwd or USER_CWD,
|
user_cwd=metadata.cwd or USER_CWD,
|
||||||
identity=metadata.identity,
|
identity=metadata.identity,
|
||||||
)
|
)
|
||||||
|
backend_name = metadata.backend or None
|
||||||
return _launch_bottle(
|
return _launch_bottle(
|
||||||
spec,
|
spec,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
remote_control=args.remote_control,
|
remote_control=args.remote_control,
|
||||||
|
backend_name=backend_name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
DEFAULT_PORT = 9420
|
DEFAULT_PORT = 9420
|
||||||
|
|
||||||
|
# Body-size cap matching supervise_server.py's 1 MiB limit.
|
||||||
|
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class GitHttpHandler(BaseHTTPRequestHandler):
|
class GitHttpHandler(BaseHTTPRequestHandler):
|
||||||
server_version = "bot-bottle-git-http/1"
|
server_version = "bot-bottle-git-http/1"
|
||||||
@@ -76,7 +79,18 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
value = self.headers.get(header)
|
value = self.headers.get(header)
|
||||||
if value:
|
if value:
|
||||||
env[variable] = value
|
env[variable] = value
|
||||||
length = int(self.headers.get("content-length", "0") or "0")
|
raw_length = self.headers.get("content-length", "0") or "0"
|
||||||
|
try:
|
||||||
|
length = int(raw_length)
|
||||||
|
except ValueError:
|
||||||
|
self.send_error(400, "Bad Content-Length")
|
||||||
|
return
|
||||||
|
if length < 0:
|
||||||
|
self.send_error(400, "Negative Content-Length")
|
||||||
|
return
|
||||||
|
if length > MAX_BODY_BYTES:
|
||||||
|
self.send_error(413, "Request body too large")
|
||||||
|
return
|
||||||
body = self.rfile.read(length) if length else b""
|
body = self.rfile.read(length) if length else b""
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["git", "http-backend"],
|
["git", "http-backend"],
|
||||||
|
|||||||
@@ -245,7 +245,12 @@ class _Supervisor:
|
|||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return all(p.poll() is not None for _, p in self.procs)
|
done = all(p.poll() is not None for _, p in self.procs)
|
||||||
|
if done:
|
||||||
|
for _, p in self.procs:
|
||||||
|
if p.stdout is not None:
|
||||||
|
p.stdout.close()
|
||||||
|
return done
|
||||||
|
|
||||||
def exit_code(self) -> int:
|
def exit_code(self) -> int:
|
||||||
"""Positive child failures win; otherwise report success.
|
"""Positive child failures win; otherwise report success.
|
||||||
@@ -335,6 +340,8 @@ class _Supervisor:
|
|||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
p.wait()
|
p.wait()
|
||||||
|
if p.stdout is not None:
|
||||||
|
p.stdout.close()
|
||||||
self._logged_dead.discard(daemon_name)
|
self._logged_dead.discard(daemon_name)
|
||||||
new_proc = _spawn(spec)
|
new_proc = _spawn(spec)
|
||||||
self.procs[idx] = (spec, new_proc)
|
self.procs[idx] = (spec, new_proc)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# PRD 0038: smolmachines Env Contract and Secret-Safe Injection
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-codex
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #135
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make smolmachines env handling match Docker's contract: resolve manifest env
|
||||||
|
entries through `resolve_env()`, keep secret and interpolated values out of
|
||||||
|
host argv, and document or enforce an explicit env contract for the backend.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`bot_bottle/backend/smolmachines/prepare.py` builds the guest env from
|
||||||
|
`bottle.env` directly, bypassing `resolve_env()`. Entries like `?prompt` and
|
||||||
|
`${HOST_VAR}` can reach the guest literally rather than being prompted or
|
||||||
|
resolved. In contrast, Docker resolves env through `resolve_env()` before
|
||||||
|
writing a mode-600 env file.
|
||||||
|
|
||||||
|
`smolmachines/smolvm.py` renders env as `-e KEY=VALUE` on `smolvm machine
|
||||||
|
create` argv, and `SmolmachinesBottle.agent_argv` / `exec` prepend
|
||||||
|
`env KEY=VALUE …` onto the `smolvm machine exec` argv. Any literal or resolved
|
||||||
|
secret value is therefore visible in the host process table.
|
||||||
|
|
||||||
|
The two backends have no shared env contract document. Divergence will silently
|
||||||
|
widen as new manifest env features are added.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- Manifest env entries are resolved through `resolve_env()` before being
|
||||||
|
injected into the smolmachines guest, matching Docker behaviour.
|
||||||
|
- No manifest env value (literal or resolved) appears on host argv during
|
||||||
|
machine creation or exec.
|
||||||
|
- Define and document an explicit smolmachines env contract covering literals,
|
||||||
|
`?prompt` secrets, and `${HOST_VAR}` interpolations.
|
||||||
|
- Unit tests cover: literal passthrough, prompted-secret resolution,
|
||||||
|
host-var interpolation, and the no-argv-leak invariant.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to the Docker env path.
|
||||||
|
- No changes to manifest schema or `resolve_env()` itself.
|
||||||
|
- No changes to smolmachines networking or mount handling.
|
||||||
|
- No new runtime dependencies.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `bot_bottle/backend/smolmachines/prepare.py` env resolution.
|
||||||
|
- `bot_bottle/backend/smolmachines/smolvm.py` machine-create argv.
|
||||||
|
- `bot_bottle/backend/smolmachines/bottle.py` `agent_argv` / `exec` env
|
||||||
|
injection.
|
||||||
|
- `bot_bottle/env.py` if helper changes are needed to support the smolmachines
|
||||||
|
path.
|
||||||
|
- Unit tests in `tests/unit/` covering the above.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Integration tests that start a live smolmachines VM.
|
||||||
|
- Docker backend changes.
|
||||||
|
- Dashboard or CLI changes.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Run smolmachines env through `resolve_env()` at prepare time, exactly as Docker
|
||||||
|
does. After resolution, inject env into the guest through a mechanism that does
|
||||||
|
not expose values on host argv — for example by writing a mode-600 env file
|
||||||
|
into the machine's state directory and loading it at exec time, or by passing
|
||||||
|
env through `smolvm`'s stdin if the tool supports it.
|
||||||
|
|
||||||
|
If `smolvm` provides no stdin or env-file injection path, document this as a
|
||||||
|
known limitation and at minimum move env values behind a per-invocation
|
||||||
|
tmpfile rather than inline argv.
|
||||||
|
|
||||||
|
The env contract for smolmachines should mirror Docker's:
|
||||||
|
|
||||||
|
- Literals: passed as-is after resolution.
|
||||||
|
- `?prompt` entries: prompted at prepare time; resolved value injected, never
|
||||||
|
on argv.
|
||||||
|
- `${HOST_VAR}` entries: interpolated from the operator's env at prepare time;
|
||||||
|
resolved value injected, never on argv.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit tests for `prepare.py` asserting `resolve_env()` is called and that
|
||||||
|
resolution results are used rather than raw `bottle.env` values.
|
||||||
|
- Unit tests for `smolvm.py` machine-create argv asserting no env value appears
|
||||||
|
inline.
|
||||||
|
- Unit tests for `bottle.py` exec path asserting the same argv invariant.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python3 -m unittest tests.unit.test_smolmachines_prepare`
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does `smolvm machine create` support an env-file flag or stdin injection that
|
||||||
|
avoids `-e KEY=VALUE` argv?
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# PRD 0039: smolmachines Capability-Block Remediation
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-codex
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #136
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make capability-block remediation backend-aware. Today the dashboard approval
|
||||||
|
path calls Docker-only teardown and apply code regardless of which backend
|
||||||
|
created the bottle. Either implement smolmachines remediation or add a clean
|
||||||
|
disable/unsupported path so operators never get a partial Docker teardown
|
||||||
|
against a smolmachines slug.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`bot_bottle/cli/dashboard.py` dispatches every capability-block approval to
|
||||||
|
`bot_bottle/backend/docker/capability_apply.py`. That code snapshots with
|
||||||
|
`docker cp`, pushes via `docker exec`, rewrites a Dockerfile override, and
|
||||||
|
removes Docker containers and networks. It does not stop or delete a smolvm
|
||||||
|
machine.
|
||||||
|
|
||||||
|
smolmachines bottles still receive the capability-block supervise tool through
|
||||||
|
`backend/smolmachines/provision/supervise.py`, so agents can queue a
|
||||||
|
remediation the host cannot correctly apply. A partial Docker teardown against
|
||||||
|
a smolmachines slug corrupts neither backend cleanly.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- Capability-block approval is routed to backend-specific code.
|
||||||
|
- For the smolmachines backend, either:
|
||||||
|
a. A real remediation implementation that stops the VM, applies the
|
||||||
|
capability change, and restarts correctly; or
|
||||||
|
b. A clean unsupported response that tells the operator the action cannot
|
||||||
|
be taken and leaves the bottle in a consistent state.
|
||||||
|
- If option (b): smolmachines agents do not receive the capability-block tool,
|
||||||
|
so the operator is never prompted for an action that will fail.
|
||||||
|
- Unit tests cover the dispatch logic and the smolmachines path.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to the Docker capability-apply path.
|
||||||
|
- No changes to other supervise tools (cred-block, pipelock-block).
|
||||||
|
- No changes to manifest or egress configuration.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `bot_bottle/cli/dashboard.py` approval dispatch.
|
||||||
|
- `bot_bottle/backend/smolmachines/provision/supervise.py` tool registration.
|
||||||
|
- New or updated backend-specific capability apply/disable module for
|
||||||
|
smolmachines.
|
||||||
|
- Unit tests for dispatch routing and smolmachines path.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Changes to `backend/docker/capability_apply.py` internals.
|
||||||
|
- Integration tests that exercise a live smolmachines VM remediation.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Introduce a backend-aware dispatch at the approval call site. Each backend
|
||||||
|
exposes a capability remediation entry point; the dashboard calls the one that
|
||||||
|
matches the bottle's backend. If the backend does not support remediation,
|
||||||
|
the entry point returns a structured error that the dashboard surfaces as an
|
||||||
|
operator message without attempting any teardown.
|
||||||
|
|
||||||
|
If option (b) is chosen initially, suppress capability-block registration in
|
||||||
|
`smolmachines/provision/supervise.py` so agents never see the tool.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit test that approval dispatch selects the smolmachines path for a
|
||||||
|
smolmachines bottle and the Docker path for a Docker bottle.
|
||||||
|
- Unit test for the smolmachines path (unsupported response or real apply).
|
||||||
|
- Regression test that Docker approval still calls `capability_apply.py`.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Is a real smolmachines capability-apply implementation in scope for this PRD,
|
||||||
|
or should it be deferred to a follow-on after PRD 0040 lands?
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# PRD 0040: Backend-Aware Resume and Dashboard Reattach
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-codex
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #137
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Persist the backend name in `BottleMetadata` and thread it through `resume` and
|
||||||
|
dashboard reattach so both flows construct the correct backend bottle without
|
||||||
|
relying on env overrides or defaulting to Docker.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`BottleMetadata` records identity, agent, cwd, started_at, and compose project,
|
||||||
|
but not the backend name. Without it:
|
||||||
|
|
||||||
|
- `cli/resume.py` cannot select the right backend from a preserved state dir
|
||||||
|
alone; operators must remember to set `BOT_BOTTLE_BACKEND=smolmachines`
|
||||||
|
separately.
|
||||||
|
- `cli/dashboard.py` `_bottle_for_slug` constructs a `DockerBottle` for any
|
||||||
|
externally discovered slug, so reattaching to a live smolmachines agent
|
||||||
|
from the dashboard sends Docker commands to a smolvm machine.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `BottleMetadata` includes the backend name, written at bottle creation time
|
||||||
|
for both Docker and smolmachines.
|
||||||
|
- `cli resume` reads the persisted backend name and constructs the correct
|
||||||
|
bottle type without requiring an env override.
|
||||||
|
- Dashboard reattach (`_bottle_for_slug`) reads the persisted backend name and
|
||||||
|
constructs the correct bottle type.
|
||||||
|
- Existing Docker bottles without a persisted backend name fall back to Docker
|
||||||
|
(backward-compatible default).
|
||||||
|
- Unit tests cover write, read, backward-compatible fallback, and both
|
||||||
|
resume/reattach code paths.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to manifest or egress configuration.
|
||||||
|
- No new CLI flags (backend selection at resume time should be automatic).
|
||||||
|
- No smolmachines capability-apply implementation (see PRD 0039).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `bot_bottle/backend/docker/bottle_state.py` `BottleMetadata` schema and
|
||||||
|
write path.
|
||||||
|
- `bot_bottle/backend/docker/bottle.py` and
|
||||||
|
`bot_bottle/backend/smolmachines/bottle.py` metadata write at creation.
|
||||||
|
- `bot_bottle/cli/resume.py` backend selection from metadata.
|
||||||
|
- `bot_bottle/cli/dashboard.py` `_bottle_for_slug` backend selection.
|
||||||
|
- Unit tests covering the above.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Migration tooling for existing state dirs.
|
||||||
|
- Integration tests that exercise full resume across process restarts.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Add a `backend` field to `BottleMetadata` with a default of `"docker"` for
|
||||||
|
backward compatibility. Both `DockerBottle` and `SmolmachinesBottle` write
|
||||||
|
their backend name into metadata at creation time.
|
||||||
|
|
||||||
|
`resume` reads the metadata before constructing the bottle object and selects
|
||||||
|
the appropriate backend class. `_bottle_for_slug` does the same. A helper
|
||||||
|
function in the metadata module can encapsulate the backend-name-to-class
|
||||||
|
mapping so the logic is not duplicated.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit tests for `BottleMetadata` serialisation with and without the backend
|
||||||
|
field.
|
||||||
|
- Unit tests for the backward-compatible default.
|
||||||
|
- Unit tests for `resume` selecting smolmachines vs Docker from metadata.
|
||||||
|
- Unit tests for `_bottle_for_slug` selecting smolmachines vs Docker.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# PRD 0041: Git HTTP Request Bounds
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-codex
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #138
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add Content-Length validation and a body-size cap to `git_http_backend.py` so malformed or oversized smart-HTTP requests fail cleanly rather than crashing the handler or exhausting memory.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`bot_bottle/git_http_backend.py` calls `int(self.headers.get("Content-Length", 0))` without catching `ValueError`. A request with a non-numeric Content-Length raises an unhandled exception in the request handler.
|
||||||
|
|
||||||
|
The handler reads the full declared length into memory before passing the body to `git http-backend` with no upper bound. A local or compromised client can force arbitrarily high memory use. For comparison, `supervise_server.py` caps request bodies at 1 MiB.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- A missing or non-numeric Content-Length returns HTTP 400.
|
||||||
|
- A negative Content-Length returns HTTP 400.
|
||||||
|
- A body larger than the cap (1 MiB, matching `supervise_server.py`) returns HTTP 413.
|
||||||
|
- Valid Git smart-HTTP pushes and fetches continue to work.
|
||||||
|
- Unit tests cover: missing length, non-numeric length, negative length, over-cap length, and a valid push/fetch passthrough.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to git-gate authentication or route logic.
|
||||||
|
- No changes to `supervise_server.py`.
|
||||||
|
- No streaming / chunked-transfer-encoding support.
|
||||||
|
- No TLS changes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `bot_bottle/git_http_backend.py` request parsing and body reading.
|
||||||
|
- Unit tests in `tests/unit/test_git_http_backend.py`.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Integration tests that drive a real Git client through the handler.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Wrap the Content-Length parse in a try/except and return 400 on `ValueError`. Add an explicit check for negative values. After parsing, compare the declared length against a module-level `MAX_BODY_BYTES` constant (default 1 MiB) and return 413 if exceeded. Read exactly `min(content_length, MAX_BODY_BYTES)` bytes.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit tests using `unittest.mock` to drive the handler with crafted headers.
|
||||||
|
- Test cases: no Content-Length header, `Content-Length: abc`, `Content-Length: -1`, `Content-Length: 2097152` (over cap), and a normal small POST body.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python3 -m unittest tests.unit.test_git_http_backend`
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# PRD 0042: smolmachines Cross-Backend Parity Tests
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-codex
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #139
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add tests that prove secrets, forwarded env, resume, and remediation behave
|
||||||
|
equivalently across Docker and smolmachines backends. The fixes in PRDs
|
||||||
|
0038–0040 are unverifiable without this coverage.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The existing unit suite is broad but backend-specific. There are no tests that
|
||||||
|
run the same scenario against both Docker and smolmachines and assert the
|
||||||
|
outcomes match. A regression in one backend goes undetected until a live run,
|
||||||
|
and PRDs 0038–0040 can each pass their own unit tests while the backends still
|
||||||
|
diverge at the integration boundary.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- A parity test suite that covers at least:
|
||||||
|
- Secret env injection: `?prompt` and `${HOST_VAR}` entries produce the same
|
||||||
|
guest env on both backends.
|
||||||
|
- Forwarded env: literal manifest env values reach the guest on both backends.
|
||||||
|
- Resume: a preserved bottle state dir round-trips correctly on both backends
|
||||||
|
(relies on PRD 0040 metadata).
|
||||||
|
- Remediation: capability-block approval routes to the correct backend handler
|
||||||
|
(relies on PRD 0039 dispatch).
|
||||||
|
- Each scenario is parameterised so a failure names the backend that regressed.
|
||||||
|
- Tests run without a live VM or Docker daemon (mock or stub backends).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No end-to-end agent execution tests.
|
||||||
|
- No performance or load tests.
|
||||||
|
- No changes to production code (test-only PRD).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- New test file(s) under `tests/unit/` for parity scenarios.
|
||||||
|
- Stub or mock implementations of smolmachines and Docker backends as needed.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Changes to `bot_bottle/` production code.
|
||||||
|
- CI infrastructure changes beyond adding the new test file to the discover
|
||||||
|
invocation.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- PRD 0038 should land before the env parity tests are finalised.
|
||||||
|
- PRDs 0039 and 0040 should land before the remediation and resume scenarios
|
||||||
|
are finalised; stubs can be written speculatively beforehand.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Parameterise each scenario over a list of backend factory functions. Each
|
||||||
|
factory returns a bottle instance wired to a stub subprocess layer. The test
|
||||||
|
body is backend-agnostic: it calls the same public API, captures the same
|
||||||
|
observable output, and asserts equality.
|
||||||
|
|
||||||
|
For env scenarios, capture the argv or env-file content passed to the guest
|
||||||
|
and compare against resolved manifest values. For resume, write metadata with
|
||||||
|
one backend class and read it back to verify correct selection. For remediation,
|
||||||
|
assert dispatch selects the per-backend handler.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Run as part of the standard unit discover:
|
||||||
|
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
|
||||||
|
- `python3 -m unittest tests.unit.test_backend_parity`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should parity tests live under `tests/unit/` (mock-based) or
|
||||||
|
`tests/integration/` (live infra)? Mock-based is preferred to keep CI simple.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# PRD 0043: Sidecar Pipe Lifecycle Cleanup
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** didericis-codex
|
||||||
|
- **Created:** 2026-06-02
|
||||||
|
- **Issue:** #140
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Close the unclosed child stdout pipe file descriptors that `sidecar_init.py`
|
||||||
|
leaks during restart and shutdown paths, eliminating `ResourceWarning` noise
|
||||||
|
and tightening the process lifecycle.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Unit tests for `sidecar_init.py` pass, but restart and shutdown cases emit
|
||||||
|
`ResourceWarning: unclosed file <_io.BufferedReader …>` for child stdout pipes,
|
||||||
|
originating around lines 141 and 273. The warnings indicate the restart path
|
||||||
|
leaks pipe file descriptors: a pipe opened for a stopped or replaced child is
|
||||||
|
not explicitly closed before the next child is spawned or before the supervisor
|
||||||
|
exits.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- `python3 -m unittest tests.unit.test_sidecar_init` produces no
|
||||||
|
`ResourceWarning` output.
|
||||||
|
- Pipe file descriptors for stopped or replaced child processes are explicitly
|
||||||
|
closed in the restart path.
|
||||||
|
- Pipe file descriptors for all children are explicitly closed in the shutdown
|
||||||
|
path.
|
||||||
|
- No change to the external signal or exit-code contract from PRD 0034.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No changes to restart or shutdown policy (coalescing, ordering, timeout).
|
||||||
|
- No changes to egress, pipelock, git-gate, or supervise daemon argv.
|
||||||
|
- No new runtime dependencies.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `bot_bottle/sidecar_init.py` pipe open/close lifecycle in `_Supervisor`.
|
||||||
|
- Unit tests in `tests/unit/test_sidecar_init.py` asserting no leaked pipes.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Changing how pumping threads read from pipes.
|
||||||
|
- Integration tests that start a live sidecar container.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Audit every code path in `_Supervisor` where a child process is stopped,
|
||||||
|
replaced, or reaches end-of-life, and ensure the corresponding stdout pipe is
|
||||||
|
explicitly closed before spawning a replacement or exiting the supervisor loop.
|
||||||
|
|
||||||
|
Where a pumping thread holds a reference to the pipe, coordinate closure so the
|
||||||
|
thread sees EOF and exits cleanly rather than blocking indefinitely.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Enable `ResourceWarning` as an error in test setUp:
|
||||||
|
`warnings.simplefilter("error", ResourceWarning)`.
|
||||||
|
- Run existing restart and shutdown test cases under this stricter setting.
|
||||||
|
- Add tests for restart-then-shutdown if not already covered.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `python3 -m unittest tests.unit.test_sidecar_init`
|
||||||
|
- `python3 -m unittest discover -s tests/unit`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Cross-backend parity tests (PRD 0042).
|
||||||
|
|
||||||
|
Verifies that Docker and smolmachines bottles expose the same
|
||||||
|
observable contracts for env injection, agent argv, and exec. Tests
|
||||||
|
use mock subprocess layers so no live VM or Docker daemon is needed.
|
||||||
|
|
||||||
|
The scenarios here document what must hold across both backends. As
|
||||||
|
PRDs 0038–0040 land these tests provide regression coverage for the
|
||||||
|
contracts they establish.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from typing import Callable
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _docker_bottle(guest_env: dict[str, str]) -> "object":
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
return DockerBottle(
|
||||||
|
container="bot-bottle-test",
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=None,
|
||||||
|
agent_command="claude",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _smolmachines_bottle(guest_env: dict[str, str]) -> "object":
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
return SmolmachinesBottle(
|
||||||
|
"bot-bottle-test",
|
||||||
|
guest_env=guest_env,
|
||||||
|
agent_command="claude",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# One entry per backend: (label, factory).
|
||||||
|
_BACKENDS: list[tuple[str, Callable[[dict[str, str]], object]]] = [
|
||||||
|
("docker", _docker_bottle),
|
||||||
|
("smolmachines", _smolmachines_bottle),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# agent_argv contracts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestAgentArgvParity(unittest.TestCase):
|
||||||
|
"""Both backends surface a non-empty agent_argv that includes the
|
||||||
|
agent command and can be used as a subprocess command list."""
|
||||||
|
|
||||||
|
def test_agent_argv_is_list_of_strings(self):
|
||||||
|
for label, factory in _BACKENDS:
|
||||||
|
with self.subTest(backend=label):
|
||||||
|
bottle = factory({"MY_VAR": "val"})
|
||||||
|
argv = bottle.agent_argv([], tty=False) # type: ignore[union-attr]
|
||||||
|
self.assertIsInstance(argv, list, f"{label}: argv is not a list")
|
||||||
|
for item in argv:
|
||||||
|
self.assertIsInstance(
|
||||||
|
item, str,
|
||||||
|
f"{label}: argv item {item!r} is not a str",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_command_present_in_argv(self):
|
||||||
|
for label, factory in _BACKENDS:
|
||||||
|
with self.subTest(backend=label):
|
||||||
|
bottle = factory({})
|
||||||
|
argv = bottle.agent_argv([], tty=False) # type: ignore[union-attr]
|
||||||
|
joined = " ".join(argv)
|
||||||
|
self.assertIn(
|
||||||
|
"claude", joined,
|
||||||
|
f"{label}: 'claude' not found in agent_argv",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_extra_flags_propagate(self):
|
||||||
|
extra = ["--no-update-check", "--output-format", "stream-json"]
|
||||||
|
for label, factory in _BACKENDS:
|
||||||
|
with self.subTest(backend=label):
|
||||||
|
bottle = factory({})
|
||||||
|
argv = bottle.agent_argv(extra, tty=False) # type: ignore[union-attr]
|
||||||
|
for flag in extra:
|
||||||
|
self.assertIn(
|
||||||
|
flag, argv,
|
||||||
|
f"{label}: flag {flag!r} not in agent_argv",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmolmachinesEnvInArgv(unittest.TestCase):
|
||||||
|
"""smolmachines bottle includes guest_env values in exec argv."""
|
||||||
|
|
||||||
|
def test_guest_env_in_exec_argv(self):
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
bottle = SmolmachinesBottle(
|
||||||
|
"bot-bottle-test",
|
||||||
|
guest_env={"TOKEN": "abc123", "PROXY": "http://proxy:8888"},
|
||||||
|
)
|
||||||
|
argv = bottle.agent_argv([], tty=False)
|
||||||
|
joined = " ".join(argv)
|
||||||
|
self.assertIn("TOKEN=abc123", joined)
|
||||||
|
self.assertIn("PROXY=http://proxy:8888", joined)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# exec() user-switching contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExecUserSwitching(unittest.TestCase):
|
||||||
|
"""Both backends exec as 'node' by default and accept user='root'."""
|
||||||
|
|
||||||
|
def test_docker_exec_uses_node_user_by_default(self):
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
bottle = DockerBottle(
|
||||||
|
container="bot-bottle-test",
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=None,
|
||||||
|
)
|
||||||
|
with patch("bot_bottle.backend.docker.bottle.subprocess.run") as run:
|
||||||
|
run.return_value = subprocess.CompletedProcess(
|
||||||
|
[], 0, stdout="", stderr="",
|
||||||
|
)
|
||||||
|
bottle.exec("echo hi")
|
||||||
|
call_args = run.call_args[0][0]
|
||||||
|
self.assertIn("node", call_args,
|
||||||
|
"docker exec should use 'node' user by default")
|
||||||
|
|
||||||
|
def test_smolmachines_exec_uses_node_user_by_default(self):
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||||
|
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run") as run:
|
||||||
|
run.return_value = subprocess.CompletedProcess(
|
||||||
|
[], 0, stdout="", stderr="",
|
||||||
|
)
|
||||||
|
bottle.exec("echo hi")
|
||||||
|
call_args = run.call_args[0][0]
|
||||||
|
self.assertIn("node", call_args,
|
||||||
|
"smolvm exec should use 'node' user by default")
|
||||||
|
|
||||||
|
def test_docker_exec_respects_root_user(self):
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
bottle = DockerBottle(
|
||||||
|
container="bot-bottle-test",
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=None,
|
||||||
|
)
|
||||||
|
with patch("bot_bottle.backend.docker.bottle.subprocess.run") as run:
|
||||||
|
run.return_value = subprocess.CompletedProcess(
|
||||||
|
[], 0, stdout="", stderr="",
|
||||||
|
)
|
||||||
|
bottle.exec("id", user="root")
|
||||||
|
call_args = run.call_args[0][0]
|
||||||
|
self.assertIn("root", call_args)
|
||||||
|
|
||||||
|
def test_smolmachines_exec_respects_root_user(self):
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||||
|
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run") as run:
|
||||||
|
run.return_value = subprocess.CompletedProcess(
|
||||||
|
[], 0, stdout="", stderr="",
|
||||||
|
)
|
||||||
|
bottle.exec("id", user="root")
|
||||||
|
call_args = run.call_args[0][0]
|
||||||
|
self.assertIn("root", call_args)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExecResult shape parity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExecResultParity(unittest.TestCase):
|
||||||
|
"""Both backends return ExecResult with returncode, stdout, stderr."""
|
||||||
|
|
||||||
|
def _stub_run(self, argv, **kwargs):
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
argv, 0, stdout="out\n", stderr="err\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_docker_exec_result_shape(self):
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
from bot_bottle.backend import ExecResult
|
||||||
|
bottle = DockerBottle(
|
||||||
|
container="bot-bottle-test",
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=None,
|
||||||
|
)
|
||||||
|
with patch("bot_bottle.backend.docker.bottle.subprocess.run",
|
||||||
|
side_effect=self._stub_run):
|
||||||
|
result = bottle.exec("echo hi")
|
||||||
|
self.assertIsInstance(result, ExecResult)
|
||||||
|
self.assertEqual(0, result.returncode)
|
||||||
|
self.assertIsInstance(result.stdout, str)
|
||||||
|
self.assertIsInstance(result.stderr, str)
|
||||||
|
|
||||||
|
def test_smolmachines_exec_result_shape(self):
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
from bot_bottle.backend import ExecResult
|
||||||
|
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||||
|
with patch("bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
||||||
|
side_effect=self._stub_run):
|
||||||
|
result = bottle.exec("echo hi")
|
||||||
|
self.assertIsInstance(result, ExecResult)
|
||||||
|
self.assertEqual(0, result.returncode)
|
||||||
|
self.assertIsInstance(result.stdout, str)
|
||||||
|
self.assertIsInstance(result.stderr, str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# close() is a no-op / idempotent (ABC contract)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCloseParity(unittest.TestCase):
|
||||||
|
def test_docker_close_is_idempotent(self):
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
teardown_count = [0]
|
||||||
|
def count_teardown():
|
||||||
|
teardown_count[0] += 1
|
||||||
|
bottle = DockerBottle(
|
||||||
|
container="bot-bottle-test",
|
||||||
|
teardown=count_teardown,
|
||||||
|
prompt_path_in_container=None,
|
||||||
|
)
|
||||||
|
bottle.close()
|
||||||
|
bottle.close()
|
||||||
|
# DockerBottle.close calls teardown — once per call is fine;
|
||||||
|
# what matters is it doesn't raise.
|
||||||
|
|
||||||
|
def test_smolmachines_close_is_noop(self):
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
bottle = SmolmachinesBottle("bot-bottle-test", guest_env={})
|
||||||
|
bottle.close()
|
||||||
|
bottle.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -216,5 +216,112 @@ class TestBottleMetadata(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual("t2", loaded.started_at)
|
self.assertEqual("t2", loaded.started_at)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
"""PRD 0040: backend field is persisted and read back."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
|
def test_backend_field_roundtrips_docker(self):
|
||||||
|
meta = BottleMetadata(
|
||||||
|
identity="dev-b1",
|
||||||
|
agent_name="dev",
|
||||||
|
cwd="",
|
||||||
|
copy_cwd=False,
|
||||||
|
started_at="2026-06-02T00:00:00+00:00",
|
||||||
|
compose_project="bot-bottle-dev-b1",
|
||||||
|
backend="docker",
|
||||||
|
)
|
||||||
|
write_metadata(meta)
|
||||||
|
loaded = read_metadata("dev-b1")
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual("docker", loaded.backend)
|
||||||
|
|
||||||
|
def test_backend_field_roundtrips_smolmachines(self):
|
||||||
|
meta = BottleMetadata(
|
||||||
|
identity="dev-b2",
|
||||||
|
agent_name="dev",
|
||||||
|
cwd="",
|
||||||
|
copy_cwd=False,
|
||||||
|
started_at="2026-06-02T00:00:00+00:00",
|
||||||
|
compose_project="",
|
||||||
|
backend="smolmachines",
|
||||||
|
)
|
||||||
|
write_metadata(meta)
|
||||||
|
loaded = read_metadata("dev-b2")
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual("smolmachines", loaded.backend)
|
||||||
|
|
||||||
|
def test_missing_backend_field_defaults_to_empty(self):
|
||||||
|
# Old state dirs written before PRD 0040 have no backend key.
|
||||||
|
import json
|
||||||
|
from bot_bottle.backend.docker import bottle_state as bs
|
||||||
|
path = bs.metadata_path("dev-b3")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"identity": "dev-b3",
|
||||||
|
"agent_name": "dev",
|
||||||
|
"cwd": "",
|
||||||
|
"copy_cwd": False,
|
||||||
|
"started_at": "2026-06-02T00:00:00+00:00",
|
||||||
|
"compose_project": "bot-bottle-dev-b3",
|
||||||
|
}))
|
||||||
|
loaded = read_metadata("dev-b3")
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual("", loaded.backend)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBottleForSlugBackend(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
"""PRD 0040: _bottle_for_slug constructs the right bottle type."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
|
def test_docker_metadata_returns_docker_bottle(self):
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
from bot_bottle.cli.dashboard import _bottle_for_slug
|
||||||
|
write_metadata(BottleMetadata(
|
||||||
|
identity="dev-d1",
|
||||||
|
agent_name="dev",
|
||||||
|
cwd="",
|
||||||
|
copy_cwd=False,
|
||||||
|
started_at="2026-06-02T00:00:00+00:00",
|
||||||
|
compose_project="bot-bottle-dev-d1",
|
||||||
|
backend="docker",
|
||||||
|
))
|
||||||
|
bottle, _ = _bottle_for_slug("dev-d1", {}, None)
|
||||||
|
self.assertIsInstance(bottle, DockerBottle)
|
||||||
|
|
||||||
|
def test_smolmachines_metadata_returns_smolmachines_bottle(self):
|
||||||
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
||||||
|
from bot_bottle.cli.dashboard import _bottle_for_slug
|
||||||
|
write_metadata(BottleMetadata(
|
||||||
|
identity="dev-s1",
|
||||||
|
agent_name="dev",
|
||||||
|
cwd="",
|
||||||
|
copy_cwd=False,
|
||||||
|
started_at="2026-06-02T00:00:00+00:00",
|
||||||
|
compose_project="",
|
||||||
|
backend="smolmachines",
|
||||||
|
))
|
||||||
|
bottle, _ = _bottle_for_slug("dev-s1", {}, None)
|
||||||
|
self.assertIsInstance(bottle, SmolmachinesBottle)
|
||||||
|
|
||||||
|
def test_no_metadata_defaults_to_docker_bottle(self):
|
||||||
|
from bot_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
from bot_bottle.cli.dashboard import _bottle_for_slug
|
||||||
|
bottle, _ = _bottle_for_slug("unknown-slug", {}, None)
|
||||||
|
self.assertIsInstance(bottle, DockerBottle)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -577,5 +577,54 @@ class TestEditInEditor(unittest.TestCase):
|
|||||||
os.environ["EDITOR"] = original_editor
|
os.environ["EDITOR"] = original_editor
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
"""approve() must refuse capability-block for smolmachines bottles and
|
||||||
|
pass it through for Docker bottles (PRD 0039)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._setup_fake_home()
|
||||||
|
self._original_apply_capability = dashboard.apply_capability_change
|
||||||
|
dashboard.apply_capability_change = lambda slug, content: ("", content)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
dashboard.apply_capability_change = self._original_apply_capability
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
|
def _enqueue_capability(self, slug: str = "dev") -> "dashboard.QueuedProposal":
|
||||||
|
p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK)
|
||||||
|
qdir = supervise.queue_dir_for_slug(slug)
|
||||||
|
qdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
supervise.write_proposal(qdir, p)
|
||||||
|
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
|
||||||
|
|
||||||
|
def _write_metadata(self, slug: str, compose_project: str) -> None:
|
||||||
|
from bot_bottle.backend.docker.bottle_state import BottleMetadata, write_metadata
|
||||||
|
write_metadata(BottleMetadata(
|
||||||
|
identity=slug,
|
||||||
|
agent_name="myagent",
|
||||||
|
cwd="",
|
||||||
|
copy_cwd=False,
|
||||||
|
started_at="2026-06-02T00:00:00+00:00",
|
||||||
|
compose_project=compose_project,
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_smolmachines_bottle_raises_capability_apply_error(self):
|
||||||
|
self._write_metadata("dev", compose_project="")
|
||||||
|
qp = self._enqueue_capability("dev")
|
||||||
|
with self.assertRaises(CapabilityApplyError) as ctx:
|
||||||
|
dashboard.approve(qp)
|
||||||
|
self.assertIn("smolmachines", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_docker_bottle_calls_apply_capability_change(self):
|
||||||
|
self._write_metadata("dev", compose_project="bot-bottle-dev")
|
||||||
|
qp = self._enqueue_capability("dev")
|
||||||
|
dashboard.approve(qp) # must not raise
|
||||||
|
|
||||||
|
def test_no_metadata_falls_through_to_docker_path(self):
|
||||||
|
# No metadata at all → assume Docker (backward-compatible).
|
||||||
|
qp = self._enqueue_capability("dev")
|
||||||
|
dashboard.approve(qp) # must not raise
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -165,5 +165,77 @@ class TestGitHttpBackend(unittest.TestCase):
|
|||||||
os.environ["GIT_GATE_ACCESS_HOOK"] = value
|
os.environ["GIT_GATE_ACCESS_HOOK"] = value
|
||||||
|
|
||||||
|
|
||||||
|
class TestContentLengthBounds(unittest.TestCase):
|
||||||
|
"""PRD 0041: malformed or oversized Content-Length is rejected before
|
||||||
|
git http-backend is invoked."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
import tempfile, os
|
||||||
|
self._tmp = tempfile.mkdtemp()
|
||||||
|
os.environ["GIT_PROJECT_ROOT"] = self._tmp
|
||||||
|
self._server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._server.serve_forever, daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
self._port = self._server.server_port
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
|
os.environ.pop("GIT_PROJECT_ROOT", None)
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def _post(self, path: str, *, content_length_header: str,
|
||||||
|
body: bytes = b"x") -> int:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{self._port}{path}",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
req.add_header("Content-Length", content_length_header)
|
||||||
|
req.add_header("Content-Type", "application/x-git-receive-pack-request")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||||
|
return resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code
|
||||||
|
|
||||||
|
def test_non_numeric_content_length_returns_400(self):
|
||||||
|
status = self._post("/repo.git/git-receive-pack",
|
||||||
|
content_length_header="abc")
|
||||||
|
self.assertEqual(400, status)
|
||||||
|
|
||||||
|
def test_negative_content_length_returns_400(self):
|
||||||
|
status = self._post("/repo.git/git-receive-pack",
|
||||||
|
content_length_header="-1")
|
||||||
|
self.assertEqual(400, status)
|
||||||
|
|
||||||
|
def test_oversized_content_length_returns_413(self):
|
||||||
|
# Declare 2 MiB — over the 1 MiB cap.
|
||||||
|
status = self._post("/repo.git/git-receive-pack",
|
||||||
|
content_length_header=str(2 * 1024 * 1024))
|
||||||
|
self.assertEqual(413, status)
|
||||||
|
|
||||||
|
def test_valid_small_body_passes_through(self):
|
||||||
|
# With a valid Content-Length the handler proceeds into
|
||||||
|
# git http-backend; that will fail (no real git repo) but the
|
||||||
|
# status won't be 400 or 413.
|
||||||
|
with mock.patch("bot_bottle.git_http_backend.subprocess.run") as run:
|
||||||
|
run.return_value = mock.Mock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=(
|
||||||
|
b"Status: 200 OK\r\n"
|
||||||
|
b"Content-Type: application/x-git-receive-pack-result\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status = self._post("/repo.git/git-receive-pack",
|
||||||
|
content_length_header="1", body=b"x")
|
||||||
|
self.assertNotIn(status, (400, 413))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -135,6 +136,10 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
We don't go through `main()` because main installs signal
|
We don't go through `main()` because main installs signal
|
||||||
handlers process-wide, which collides with the test runner."""
|
handlers process-wide, which collides with the test runner."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
warnings.simplefilter("error", ResourceWarning)
|
||||||
|
self.addCleanup(warnings.resetwarnings)
|
||||||
|
|
||||||
def _drive(self, sup: _Supervisor, max_wait_s: float = 6.0) -> int:
|
def _drive(self, sup: _Supervisor, max_wait_s: float = 6.0) -> int:
|
||||||
deadline = time.monotonic() + max_wait_s
|
deadline = time.monotonic() + max_wait_s
|
||||||
while not sup.tick():
|
while not sup.tick():
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Unit: smolmachines prepare.py env resolution (PRD 0038)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
|
from bot_bottle.env import ResolvedEnv
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmolmachinesResolveEnv(unittest.TestCase):
|
||||||
|
"""resolve_plan() must call resolve_env() and build guest_env
|
||||||
|
from the resolved values rather than from raw bottle.env."""
|
||||||
|
|
||||||
|
def _run_resolve_plan(
|
||||||
|
self,
|
||||||
|
resolved: ResolvedEnv,
|
||||||
|
*,
|
||||||
|
extra_host_env: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from bot_bottle.backend import BottleSpec
|
||||||
|
from bot_bottle.manifest import Manifest
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
stage = Path(tmp) / "stage"
|
||||||
|
stage.mkdir()
|
||||||
|
|
||||||
|
# Minimal manifest with one env literal so the spec is valid.
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"agents": {"myagent": {"bottle": "mybottle"}},
|
||||||
|
"bottles": {"mybottle": {"env": {"PLAIN": "literal-value"}}},
|
||||||
|
})
|
||||||
|
spec = BottleSpec(
|
||||||
|
manifest=manifest,
|
||||||
|
agent_name="myagent",
|
||||||
|
copy_cwd=False,
|
||||||
|
user_cwd=tmp,
|
||||||
|
identity="test-slug-00001",
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot_bottle import supervise as _sup
|
||||||
|
orig_root = _sup.bot_bottle_root
|
||||||
|
_sup.bot_bottle_root = lambda: Path(tmp) / ".bot-bottle" # type: ignore[assignment]
|
||||||
|
|
||||||
|
host_env = {**os.environ, **(extra_host_env or {})}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.resolve_env",
|
||||||
|
return_value=resolved) as mock_resolve,
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_preflight"),
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet",
|
||||||
|
return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")),
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.GitGate") as mock_gg,
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl,
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg,
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.agent_provision_plan") as mock_app,
|
||||||
|
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
|
||||||
|
):
|
||||||
|
mock_gg.return_value.prepare.return_value = MagicMock()
|
||||||
|
mock_pl.return_value.prepare.return_value = MagicMock()
|
||||||
|
mock_eg.return_value.prepare.return_value = MagicMock()
|
||||||
|
def _make_provision(**kwargs):
|
||||||
|
return AgentProvisionPlan(
|
||||||
|
template="claude",
|
||||||
|
command="claude",
|
||||||
|
prompt_mode="append_file",
|
||||||
|
dockerfile="",
|
||||||
|
image="bot-bottle-claude:latest",
|
||||||
|
guest_env=dict(kwargs.get("guest_env") or {}),
|
||||||
|
)
|
||||||
|
mock_app.side_effect = lambda **kw: _make_provision(**kw)
|
||||||
|
|
||||||
|
from bot_bottle.backend.smolmachines.prepare import resolve_plan
|
||||||
|
plan = resolve_plan(spec, stage_dir=stage)
|
||||||
|
|
||||||
|
mock_resolve.assert_called_once_with(manifest, "myagent")
|
||||||
|
return dict(plan.guest_env)
|
||||||
|
finally:
|
||||||
|
_sup.bot_bottle_root = orig_root # type: ignore[assignment]
|
||||||
|
|
||||||
|
def test_literal_env_reaches_guest_env(self):
|
||||||
|
resolved = ResolvedEnv(
|
||||||
|
literals={"PLAIN": "hello"},
|
||||||
|
forwarded={},
|
||||||
|
)
|
||||||
|
guest_env = self._run_resolve_plan(resolved)
|
||||||
|
self.assertEqual("hello", guest_env["PLAIN"])
|
||||||
|
|
||||||
|
def test_forwarded_env_reaches_guest_env(self):
|
||||||
|
# Secrets / interpolated values land in forwarded; they must
|
||||||
|
# still reach the guest (argv exposure is the known gap).
|
||||||
|
resolved = ResolvedEnv(
|
||||||
|
literals={},
|
||||||
|
forwarded={"SECRET": "s3cr3t", "INTERP": "resolved-val"},
|
||||||
|
)
|
||||||
|
guest_env = self._run_resolve_plan(resolved)
|
||||||
|
self.assertEqual("s3cr3t", guest_env["SECRET"])
|
||||||
|
self.assertEqual("resolved-val", guest_env["INTERP"])
|
||||||
|
|
||||||
|
def test_raw_manifest_sentinel_not_in_guest_env(self):
|
||||||
|
# Before the fix, ?prompt and ${HOST} would appear verbatim.
|
||||||
|
# After the fix, resolve_env() is called so the caller sees
|
||||||
|
# the mocked resolved values (no raw sentinel survives).
|
||||||
|
resolved = ResolvedEnv(
|
||||||
|
literals={},
|
||||||
|
forwarded={"MY_SECRET": "actual-value"},
|
||||||
|
)
|
||||||
|
guest_env = self._run_resolve_plan(resolved)
|
||||||
|
for v in guest_env.values():
|
||||||
|
self.assertFalse(
|
||||||
|
v.startswith("?"),
|
||||||
|
f"raw secret sentinel survived in guest_env: {v!r}",
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
v.startswith("${"),
|
||||||
|
f"raw interpolation sentinel survived in guest_env: {v!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tls_trust_env_always_present(self):
|
||||||
|
resolved = ResolvedEnv(literals={}, forwarded={})
|
||||||
|
guest_env = self._run_resolve_plan(resolved)
|
||||||
|
for key in ("NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
|
||||||
|
self.assertIn(key, guest_env, f"{key} missing from guest_env")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user