diff --git a/tako_vm/execution/docker.py b/tako_vm/execution/docker.py index 765773e..803f035 100644 --- a/tako_vm/execution/docker.py +++ b/tako_vm/execution/docker.py @@ -166,6 +166,13 @@ def image_has_executor_entrypoint(image_name: str) -> Optional[bool]: # label=tako-vm.execution-id=`). EXECUTION_ID_LABEL = "tako-vm.execution-id" +# Label key carrying the durable-session ID, so a long-lived session container +# can be mapped back to its session record and reaped by ID (e.g. `docker ps +# --filter label=tako-vm.session-id=`). Sessions are the Phase 1 step toward +# persistent, per-agent workspaces; unlike per-job containers they survive +# across executions, so they carry their own label alongside CONTAINER_LABEL. +SESSION_ID_LABEL = "tako-vm.session-id" + def is_native_linux() -> bool: """ @@ -358,6 +365,91 @@ def inspect_oom_killed(container_name: str) -> Optional[bool]: return None +def container_running(container_name: str) -> bool: + """Check whether a container is currently running via ``docker inspect``. + + ``State.Running`` is the authoritative signal that a session container is + still up and ready to be ``docker exec``'d into. Shells out and parses the + formatted output exactly like ``inspect_oom_killed`` so the two inspect + helpers behave identically. + + Args: + container_name: Name of the container to inspect. + + Returns: + True if ``State.Running`` is ``true``, False otherwise (not running, + container already gone, daemon unreachable, timeout, or unparseable + output). A missing/unreachable container is reported as "not running" + rather than raising, so callers can treat it as "needs (re)starting". + """ + try: + result = subprocess.run( + ["docker", "inspect", "--format", "{{.State.Running}}", container_name], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if result.returncode != 0: + logger.debug("docker inspect of %s failed (exit %s)", container_name, result.returncode) + return False + value = (result.stdout or "").strip().lower() + if value == "true": + return True + if value == "false": + return False + logger.debug("Unexpected Running value for %s: %r", container_name, result.stdout) + return False + except Exception as e: + logger.debug("Failed to inspect container %s: %s", container_name, e) + return False + + +def stop_container(container_name: str, timeout: int = 10) -> bool: + """Gracefully stop a container by name (best-effort). + + ``docker stop`` sends SIGTERM and then SIGKILL after a grace period, the + clean way to bring down a long-lived session container (vs. ``kill_container``'s + immediate SIGKILL). Mirrors ``remove_container``'s error-swallowing style: + a missing container is benign, any other failure is logged but not raised. + + Args: + container_name: Name of the container to stop. + timeout: Seconds ``docker stop`` waits before sending SIGKILL. + + Returns: + True if Docker reported the container was stopped, False otherwise. + """ + try: + result = subprocess.run( + ["docker", "stop", "-t", str(timeout), container_name], + capture_output=True, + timeout=timeout + 10, + check=False, + ) + if result.returncode == 0: + logger.debug("Stopped container %s", container_name) + return True + # A missing container is benign (already stopped / never started); any + # other non-zero exit is a genuine stop failure worth surfacing. + stderr = decode_subprocess_stream(result.stderr).strip() + if "no such container" in stderr.lower(): + logger.debug("Container %s did not exist", container_name) + else: + logger.warning( + "Failed to stop container %s (exit %s): %s", + container_name, + result.returncode, + stderr, + ) + return False + except Exception as e: + # A docker CLI error (daemon unreachable, timeout) leaves the container + # state unknown; surface it rather than silently swallowing. + logger.warning("Failed to stop container %s: %s", container_name, e) + return False + + def ulimit_args(limits) -> list[str]: """Return the ``--ulimit`` flags shared by every execution path. @@ -464,6 +556,159 @@ def base_isolation_args( return args +def build_session_run_command( + container_name: str, + *, + runtime: str, + image: str, + workspace_dir: str, + session_id: str, + memory_limit: Optional[str] = None, + cpu_limit: Optional[str] = None, + enable_cap_restrictions: bool = True, + keepalive_cmd: Optional[Sequence[str]] = None, +) -> list[str]: + """Build the ``docker run`` command for a long-lived session container. + + Pure function: assembles and returns the argument list, runs no subprocess, + so it can be unit-tested without Docker. + + Unlike a per-job container, a session container is *long-lived*: it stays up + across many ``docker exec`` calls (the durable, per-agent workspace step of + the roadmap). It therefore starts from ``base_isolation_args(..., + auto_remove=False)`` so the gVisor isolation posture is byte-for-byte + identical to a job's (same ``--read-only``/``--init``/cap-drop set/runtime) + AND the daemon does not reap it on the keepalive process exiting. Reusing + ``base_isolation_args`` is the whole point: the isolation flags are never + re-assembled by hand here, so they cannot drift from the job path. + + Session-specific flags appended on top: + + - ``-d`` so the container runs detached (the caller exec's into it later). + - ``--network=none``: Phase 1 sessions have NO egress. (Matches the worker's + glued ``--network=...`` spelling.) + - ``--label={SESSION_ID_LABEL}={session_id}`` so the container is traceable + to its session record and reapable by label. + - ``-v {workspace_dir}:/workspace``: the persistent workspace, mounted + read-WRITE. This is the only writable cross-exec surface, so it is + deliberately NOT ``:ro``; everything else stays read-only. + - ``--memory=`` / ``--cpus=`` when provided (worker's glued spelling). + + Args: + container_name: Container name (``--name``). + runtime: Resolved container runtime ('runsc' or 'runc'); only 'runsc' + is passed explicitly (see ``base_isolation_args``). + image: Image reference to run. + workspace_dir: Host path bind-mounted read-write at ``/workspace``. + session_id: Durable session ID recorded as the ``SESSION_ID_LABEL``. + memory_limit: Optional ``--memory`` value (e.g. "512m"); omitted if None. + cpu_limit: Optional ``--cpus`` value (e.g. "1.0"); omitted if None. + enable_cap_restrictions: Forwarded to ``base_isolation_args``. + keepalive_cmd: Container command that keeps it alive; defaults to + ``["sleep", "infinity"]``. + + Returns: + The full ``docker run`` argument list, ready to execute. + """ + cmd = base_isolation_args( + container_name, + runtime=runtime, + enable_cap_restrictions=enable_cap_restrictions, + auto_remove=False, # Long-lived: the daemon must NOT reap the container. + ) + + # Detached: the container stays up so the caller can `docker exec` into it. + cmd.append("-d") + # Phase 1 sessions have no egress. (Worker uses the same glued spelling.) + cmd.append("--network=none") + # Trace the container back to its session record / reap it by label. + cmd.append(f"--label={SESSION_ID_LABEL}={session_id}") + # The persistent workspace: the ONLY writable cross-exec surface. Mounted + # read-write on purpose (no ``:ro``); the rest of the rootfs is read-only. + cmd.append("-v") + cmd.append(f"{workspace_dir}:/workspace") + + # Resource limits, matching the worker's glued ``--memory=``/``--cpus=`` + # spelling. Only emitted when provided. + if memory_limit is not None: + cmd.append(f"--memory={memory_limit}") + if cpu_limit is not None: + cmd.append(f"--cpus={cpu_limit}") + + # Image, then the keepalive command. The real PID1 supervisor + # (session_entrypoint.sh) lands in Phase 1b; until then the keepalive is a + # placeholder process that simply keeps the container alive between execs. + cmd.append(image) + cmd.extend(keepalive_cmd if keepalive_cmd is not None else ["sleep", "infinity"]) + + return cmd + + +def build_session_exec_command( + container_name: str, + *, + command: Sequence[str], + timeout_seconds: Optional[float] = None, + workdir: str = "/workspace", +) -> list[str]: + """Build the ``docker exec`` command that runs ``command`` in a live session. + + Pure function: returns the argument list, runs no subprocess, so it can be + unit-tested without Docker. + + The command runs in ``/workspace`` (the writable session surface) and is + dropped to the unprivileged sandbox user via the numeric ``-u 1000:1000`` + (not a username, so the drop never depends on name resolution and can never + resolve to root). ``--privileged`` and ``-u 0``/root are never emitted. + + Provisional Phase-1 drop: this is NOT yet the final warm-container exec + model. The job run path's default posture is root-then-``gosu`` (the + container starts as root so the entrypoint can write the root-only + ``/tako-meta`` timing channel, then drops to uid 1000); ``--user=1000:1000`` + is only set when ``enable_userns`` is on (off by default). A bare + ``-u 1000:1000`` exec cannot write ``/tako-meta``, so per-exec OOM/timeout + classification on a warm container is deferred to the Phase 1b PID1 + supervisor (which will exec as root and ``gosu``-drop per command). + + When ``timeout_seconds`` is given, the user command is wrapped in GNU + ``timeout`` so a runaway exec cannot outlive its budget (mirroring the + entrypoint's ``timeout`` wrapper around the in-container run). + + Args: + container_name: Name of the live session container to exec into. + command: The user command and its args (e.g. ``["python", "-c", ...]``). + timeout_seconds: Optional GNU-``timeout`` budget in seconds; omitted if + None. + workdir: Working directory inside the container (defaults to + ``/workspace``). + + Returns: + The full ``docker exec`` argument list, ready to execute. + """ + cmd = [ + "docker", + "exec", + # Drop to the unprivileged sandbox user (uid 1000); numeric uid:gid so + # it can never resolve to root. Mirrors the run path's --user=1000:1000. + "-u", + "1000:1000", + # Run in the writable workspace surface. + "-w", + workdir, + container_name, + ] + + # Wrap the user command in GNU `timeout` so a runaway exec cannot outlive + # its budget. --signal=TERM/--kill-after mirror the entrypoint's wrapper so + # code that traps SIGTERM is still SIGKILLed after a grace period. + if timeout_seconds is not None: + cmd.extend(["timeout", "--signal=TERM", "--kill-after=10s", f"{timeout_seconds}s"]) + + cmd.extend(command) + + return cmd + + def prepare_requirements_file( requirements: Optional[Sequence[str]], input_dir: Path, diff --git a/tests/test_session_lifecycle_commands.py b/tests/test_session_lifecycle_commands.py new file mode 100644 index 0000000..288db7d --- /dev/null +++ b/tests/test_session_lifecycle_commands.py @@ -0,0 +1,390 @@ +"""Executable specification of the session-container command builders. + +Phase 1 introduces long-lived *session* containers: durable, per-agent +workspaces that survive across many ``docker exec`` executions. ``docker.py`` +grows two pure command builders for them, and these tests pin their output so +the session path can never silently weaken the isolation posture that +``base_isolation_args`` (the single source of truth) guarantees, nor leak a +dangerous flag, nor run a session exec as root. + +Mirrors ``tests/test_isolation_invariants.py``: deliberately Docker-free, these +assert on the *assembled command* across a cross-product of the optional knobs, +not on a running container, so they run everywhere, fast, and gate every PR. + +The builders under test are pure (no subprocess), so nothing here touches the +daemon. The reuse of ``base_isolation_args`` is the security crux: the session +run command MUST carry every isolation flag a job carries. +""" + +import pytest + +from tako_vm.execution.docker import ( + CONTAINER_LABEL, + SESSION_ID_LABEL, + base_isolation_args, + build_session_exec_command, + build_session_run_command, +) + +# Flags that would silently break out of, or substantially weaken, the sandbox. +# Neither builder may ever emit any of these, under any argument combo. Mirrors +# the denylist in test_isolation_invariants.py and extends it for the session +# specifics (a writable host root, an explicit --rm that would reap the session, +# privilege escalation). +DANGEROUS_FLAG_PREFIXES = ( + "--privileged", + "--network=host", + "--net=host", + "--network=bridge", # sessions have NO egress in Phase 1 + "--pid=host", + "--ipc=host", + "--uts=host", + "--userns=host", + "--cap-add=ALL", + "--cap-add=SYS_ADMIN", + "--cap-add=NET_ADMIN", + "--cap-add=SYS_PTRACE", + "--cap-add=SYS_MODULE", + "--security-opt=seccomp=unconfined", + "--security-opt=apparmor=unconfined", + "--security-opt=label=disable", + "--security-opt=systempaths=unconfined", + # Host filesystem / device access beyond the single workspace bind. A bind + # mount of / or a raw device is a trivial escape. + "--mount", + "--device", +) + +# A host bind of "/" (or any token that mounts the host root) would be a trivial +# escape. The only -v value allowed is the workspace mount. +HOST_ROOT_MOUNTS = ("/:/", "/:", "//", "/:/workspace") + +# The only capabilities allowed back in: gosu needs SETUID/SETGID to drop from +# root to the unprivileged sandbox user. Nothing else. +ALLOWED_CAP_ADDS = {"--cap-add=SETUID", "--cap-add=SETGID"} + +WORKSPACE_DIR = "/srv/sessions/sess-abc123/workspace" +IMAGE = "code-executor:latest" +SESSION_ID = "sess-abc123" + +# Every combination of the optional run-command knobs, as +# ``(runtime, caps, memory, cpu)`` tuples. The invariants below must hold across +# the whole cross-product, not just the defaults. +RUN_COMBOS = [ + (runtime, caps, memory, cpu) + for runtime in ("runsc", "runc") + for caps in (True, False) + for memory in (None, "512m") + for cpu in (None, "1.5") +] + + +def _run_combo_id(combo): + runtime, caps, memory, cpu = combo + return f"{runtime}-caps{caps}-mem{bool(memory)}-cpu{bool(cpu)}" + + +def _run(runtime="runsc", caps=True, memory=None, cpu=None, **kw): + return build_session_run_command( + "tako-session-test", + runtime=runtime, + image=IMAGE, + workspace_dir=WORKSPACE_DIR, + session_id=SESSION_ID, + memory_limit=memory, + cpu_limit=cpu, + enable_cap_restrictions=caps, + **kw, + ) + + +# --------------------------------------------------------------------------- +# build_session_run_command: isolation reuse +# --------------------------------------------------------------------------- + + +class TestRunReusesIsolationPosture: + """The session run command must carry everything base_isolation_args + guarantees: the gVisor posture for a session is identical to a job's.""" + + def test_command_is_docker_run(self): + assert _run()[:2] == ["docker", "run"] + + def test_always_read_only_rootfs(self): + for c in RUN_COMBOS: + assert "--read-only" in _run(*c), f"--read-only missing for {_run_combo_id(c)}" + + def test_always_init(self): + for c in RUN_COMBOS: + assert "--init" in _run(*c), f"--init missing for {_run_combo_id(c)}" + + def test_always_named_and_labeled(self): + for c in RUN_COMBOS: + args = _run(*c) + assert "--name=tako-session-test" in args, _run_combo_id(c) + assert f"--label={CONTAINER_LABEL}" in args, ( + f"ownership label missing for {_run_combo_id(c)}" + ) + + def test_cap_drop_all_when_restrictions_enabled(self): + for runtime in ("runsc", "runc"): + assert "--cap-drop=ALL" in _run(runtime=runtime, caps=True) + + def test_only_setuid_setgid_readded(self): + for c in RUN_COMBOS: + args = _run(*c) + cap_adds = {a for a in args if a.startswith("--cap-add=")} + assert cap_adds <= ALLOWED_CAP_ADDS, ( + f"unexpected cap-add for {_run_combo_id(c)}: {cap_adds - ALLOWED_CAP_ADDS}" + ) + # Forbid the bare space-separated forms so a future switch can't + # silently disarm the glued-form denylist. + assert "--cap-add" not in args, f"bare --cap-add token for {_run_combo_id(c)}" + assert "--cap-drop" not in args, f"bare --cap-drop token for {_run_combo_id(c)}" + + def test_runsc_runtime_emitted(self): + assert "--runtime=runsc" in _run(runtime="runsc") + + def test_runc_runtime_is_implicit(self): + """runc is docker's default and is never named (some daemons reject + ``--runtime=runc``); the only runtime ever emitted is runsc.""" + runtime_flags = [a for a in _run(runtime="runc") if a.startswith("--runtime=")] + assert runtime_flags == [] + + def test_runtime_flag_is_only_ever_runsc(self): + for c in RUN_COMBOS: + for a in _run(*c): + if a.startswith("--runtime="): + assert a == "--runtime=runsc", ( + f"unexpected runtime flag for {_run_combo_id(c)}: {a}" + ) + + def test_run_carries_every_base_isolation_flag(self): + """Belt and suspenders: every flag base_isolation_args(auto_remove=False) + emits must survive into the session run command unchanged. If a future + refactor stops starting from base_isolation_args, this fails loudly.""" + for c in RUN_COMBOS: + runtime, caps, _memory, _cpu = c + base = base_isolation_args( + "tako-session-test", + runtime=runtime, + enable_cap_restrictions=caps, + auto_remove=False, + ) + args = _run(*c) + for flag in base: + assert flag in args, f"base flag {flag!r} dropped for {_run_combo_id(c)}" + + +# --------------------------------------------------------------------------- +# build_session_run_command: session specifics +# --------------------------------------------------------------------------- + + +class TestRunSessionSpecifics: + """The flags that make a session container different from a per-job one: + detached, no egress, long-lived (never --rm), labeled, writable workspace.""" + + def test_always_detached(self): + for c in RUN_COMBOS: + assert "-d" in _run(*c), f"-d missing for {_run_combo_id(c)}" + + def test_always_network_none(self): + """Phase 1 sessions have NO egress.""" + for c in RUN_COMBOS: + assert "--network=none" in _run(*c), f"--network=none missing for {_run_combo_id(c)}" + assert "--network=bridge" not in _run(*c), _run_combo_id(c) + + def test_never_rm(self): + """A session is long-lived: --rm would let the daemon reap it the + instant the keepalive process exits. auto_remove must be False.""" + for c in RUN_COMBOS: + assert "--rm" not in _run(*c), f"--rm leaked into session run for {_run_combo_id(c)}" + + def test_session_id_label_present(self): + for c in RUN_COMBOS: + assert f"--label={SESSION_ID_LABEL}={SESSION_ID}" in _run(*c), _run_combo_id(c) + + def test_workspace_mount_present_and_read_write(self): + """The workspace is the only writable cross-exec surface: it must be + mounted -v at /workspace, pointing at workspace_dir, and NOT :ro.""" + for c in RUN_COMBOS: + args = _run(*c) + assert "-v" in args, f"-v missing for {_run_combo_id(c)}" + idx = args.index("-v") + mount = args[idx + 1] + assert mount == f"{WORKSPACE_DIR}:/workspace", ( + f"unexpected workspace mount for {_run_combo_id(c)}: {mount!r}" + ) + assert not mount.endswith(":ro"), f"workspace mounted read-only for {_run_combo_id(c)}" + assert mount.endswith(":/workspace"), _run_combo_id(c) + + def test_only_one_volume_mount(self): + """Exactly one -v (the workspace). No surprise extra host binds.""" + for c in RUN_COMBOS: + args = _run(*c) + assert args.count("-v") == 1, f"unexpected number of -v for {_run_combo_id(c)}" + assert not any(a.startswith("--volume") for a in args), _run_combo_id(c) + assert not any(a.startswith("--mount") for a in args), _run_combo_id(c) + + def test_memory_limit_present_when_given_absent_when_none(self): + with_mem = _run(memory="512m") + assert "--memory=512m" in with_mem + without = _run(memory=None) + assert not any(a.startswith("--memory") for a in without) + + def test_cpu_limit_present_when_given_absent_when_none(self): + with_cpu = _run(cpu="1.5") + assert "--cpus=1.5" in with_cpu + without = _run(cpu=None) + assert not any(a.startswith("--cpus") for a in without) + + def test_image_present(self): + for c in RUN_COMBOS: + assert IMAGE in _run(*c), f"image missing for {_run_combo_id(c)}" + + def test_keepalive_appended_last_by_default(self): + for c in RUN_COMBOS: + args = _run(*c) + assert args[-2:] == ["sleep", "infinity"], ( + f"default keepalive not appended last for {_run_combo_id(c)}: {args[-3:]}" + ) + # And it lands AFTER the image (image then keepalive). + assert args.index(IMAGE) < len(args) - 2, _run_combo_id(c) + + def test_custom_keepalive_appended_after_image(self): + args = _run(keepalive_cmd=["tail", "-f", "/dev/null"]) + assert args[-3:] == ["tail", "-f", "/dev/null"] + assert args.index(IMAGE) == len(args) - 4 + + def test_keepalive_is_last_so_flags_precede_image(self): + """The image and keepalive are the trailing positional args; no docker + flag may appear after the image (docker would treat it as a container + arg, not a run flag).""" + for c in RUN_COMBOS: + args = _run(*c) + img_idx = args.index(IMAGE) + trailing = args[img_idx + 1 :] + # Everything after the image is the keepalive command, none of which + # should look like a docker run flag we care about. + assert all(not t.startswith("--network") for t in trailing), _run_combo_id(c) + assert all(not t.startswith("--memory") for t in trailing), _run_combo_id(c) + + +# --------------------------------------------------------------------------- +# build_session_run_command: no dangerous flags +# --------------------------------------------------------------------------- + + +class TestRunNoDangerousFlags: + @pytest.mark.parametrize("combo", RUN_COMBOS, ids=_run_combo_id) + def test_no_dangerous_flag_in_any_combo(self, combo): + args = _run(*combo) + for arg in args: + for bad in DANGEROUS_FLAG_PREFIXES: + assert not arg.startswith(bad), ( + f"dangerous flag {arg!r} emitted for {_run_combo_id(combo)}" + ) + + @pytest.mark.parametrize("combo", RUN_COMBOS, ids=_run_combo_id) + def test_no_host_root_bind(self, combo): + """The single -v must never mount the host root.""" + args = _run(*combo) + for arg in args: + for bad in HOST_ROOT_MOUNTS: + assert arg != bad, f"host-root bind {arg!r} for {_run_combo_id(combo)}" + + @pytest.mark.parametrize("combo", RUN_COMBOS, ids=_run_combo_id) + def test_never_runs_as_root(self, combo): + args = _run(*combo) + assert "-u" not in args or args[args.index("-u") + 1] not in ("0", "0:0", "root") + assert "--user=0" not in args + assert "--user=root" not in args + + +# --------------------------------------------------------------------------- +# build_session_exec_command +# --------------------------------------------------------------------------- + +EXEC_TIMEOUTS = (None, 30, 30.0, 0.5) + + +def _exec(command=("python", "-c", "print(1)"), timeout=None, **kw): + return build_session_exec_command( + "tako-session-test", + command=list(command), + timeout_seconds=timeout, + **kw, + ) + + +class TestExecCommand: + def test_command_is_docker_exec(self): + assert _exec()[:2] == ["docker", "exec"] + + def test_targets_named_container(self): + assert "tako-session-test" in _exec() + + def test_runs_in_workspace_by_default(self): + args = _exec() + assert "-w" in args + assert args[args.index("-w") + 1] == "/workspace" + + def test_respects_custom_workdir(self): + args = _exec(workdir="/workspace/sub") + assert args[args.index("-w") + 1] == "/workspace/sub" + + def test_drops_to_unprivileged_user(self): + """Exec must run as the unprivileged sandbox user (uid 1000), never + root. Mirrors the run path's --user=1000:1000.""" + args = _exec() + assert "-u" in args, "exec must drop privileges with -u" + assert args[args.index("-u") + 1] == "1000:1000" + + @pytest.mark.parametrize("timeout", EXEC_TIMEOUTS) + def test_never_runs_as_root(self, timeout): + args = _exec(timeout=timeout) + # -u value is never root. + assert args[args.index("-u") + 1] not in ("0", "0:0", "root") + assert "--user=0" not in args + assert "--user=0:0" not in args + assert "--user=root" not in args + # No -u 0 pair anywhere. + for i, tok in enumerate(args[:-1]): + if tok == "-u": + assert args[i + 1] not in ("0", "0:0", "root"), "exec drops to root" + + @pytest.mark.parametrize("timeout", EXEC_TIMEOUTS) + def test_no_dangerous_flag(self, timeout): + args = _exec(timeout=timeout) + for arg in args: + for bad in DANGEROUS_FLAG_PREFIXES: + assert not arg.startswith(bad), ( + f"dangerous flag {arg!r} in exec (timeout={timeout})" + ) + + def test_no_timeout_wrapper_when_none(self): + args = _exec(timeout=None) + assert "timeout" not in args + # The user command is appended directly after the container name. + assert args[-3:] == ["python", "-c", "print(1)"] + + def test_timeout_wraps_command_when_given(self): + args = _exec(timeout=30) + assert "timeout" in args, "GNU timeout wrapper missing" + t_idx = args.index("timeout") + # The duration carries an 's' suffix and the user command follows. + assert f"{30}s" in args, "timeout duration missing" + assert args[-3:] == ["python", "-c", "print(1)"], "user command not after timeout wrapper" + # The wrapper sits before the user command. + assert t_idx < args.index("python") + + def test_timeout_targets_the_user_command_not_the_container(self): + """The timeout wrapper must come AFTER the container name (it wraps the + in-container command), never before docker's own args.""" + args = _exec(timeout=30) + assert args.index("timeout") > args.index("tako-session-test") + + def test_float_timeout_formatted(self): + args = _exec(timeout=0.5) + assert "0.5s" in args