Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/boot-init.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
branches: [main]
paths:
- "boot/init/**"
- ".github/workflows/boot-init.yml"
pull_request:
paths:
- "boot/init/**"
Expand All @@ -27,5 +28,5 @@ jobs:
working-directory: boot/init
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
4 changes: 2 additions & 2 deletions .github/workflows/build-boot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ jobs:
working-directory: boot/init
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked

build:
needs: test
Expand Down
15 changes: 9 additions & 6 deletions .github/workflows/build-os-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,12 @@ jobs:
}

# A changed carrier (boot/silkd) refolds into every image; a manual
# run or a first push (no BEFORE) rebuilds all. Otherwise only the
# git-changed image dirs rebuild.
if [[ "$CARRIER_CHANGED" == "true" || "$MANUAL" == "true" || "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then
# run, a first push (no BEFORE), or a change to how the images are
# built rebuilds all. Otherwise only the git-changed image dirs
# rebuild. The workflow diff is last so a manual run never runs git.
wf=.github/workflows
if [[ "$CARRIER_CHANGED" == "true" || "$MANUAL" == "true" || "$BEFORE" == "0000000000000000000000000000000000000000" ]] ||
git diff --name-only "$BEFORE" "$SHA" -- "$wf/build-os-images.yml" "$wf/images.yml" | grep -q .; then
all_names
all_changed=""
else
Expand Down Expand Up @@ -109,7 +112,7 @@ jobs:
needs_no_cache="true"
elif [[ -n "$all_changed" ]]; then
while IFS= read -r f; do
[[ -z "$f" ]] && continue
[[ -z "$f" || "$f" == *.md ]] && continue
depth=$(echo "$f" | awk -F'/' '{print NF}')
base_name=$(basename "$f")
if [[ "$depth" -eq 3 ]] || { [[ "$depth" -eq 4 ]] && [[ "$base_name" != "Dockerfile" ]]; }; then
Expand Down Expand Up @@ -195,7 +198,7 @@ jobs:
- name: Hash image inputs
id: inputs
run: |
hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)
hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile ! -name '*.md' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)
echo "hash=$hash" >> "$GITHUB_OUTPUT"

- name: Discover secret files
Expand Down Expand Up @@ -286,7 +289,7 @@ jobs:
- name: Hash image inputs
id: inputs
run: |
hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)
hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile ! -name '*.md' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)
echo "hash=$hash" >> "$GITHUB_OUTPUT"

- name: Discover secret files
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/build-silkd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ jobs:
working-directory: silkd
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked

build:
needs: test
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ on:
- "silkd/**"
- "protocol/**"
- "os-image/**"
- ".github/workflows/images.yml"
- ".github/workflows/build-os-images.yml"
- ".github/workflows/build-silkd.yml"
- ".github/workflows/build-boot.yml"
workflow_dispatch:
inputs:
no-cache:
Expand Down Expand Up @@ -55,9 +59,12 @@ jobs:
boot=true; silkd=true; osimage=true
else
changed() { git diff --name-only "$BEFORE" "$SHA" -- "$@" | grep -q .; }
changed boot/ && boot=true || boot=false
changed silkd/ protocol/ && silkd=true || silkd=false
changed os-image/ && osimage=true || osimage=false
# Each area carries its own build workflow: changing how an image is
# built must rebuild it, or the run goes green having built nothing.
wf=.github/workflows
changed boot/ $wf/build-boot.yml && boot=true || boot=false
changed silkd/ protocol/ $wf/build-silkd.yml && silkd=true || silkd=false
changed os-image/ $wf/build-os-images.yml $wf/images.yml && osimage=true || osimage=false
fi
{
echo "boot=$boot"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
# The in-repo base SDK installs first, so an adapter release never
# resolves (or fails to resolve) cocoonstack-sandbox from PyPI.
python -m pip install -q ./sdk/python
python -m pip install -q "${{ steps.pkg.outputs.dir }}" pytest ruff
python -m pip install -q "${{ steps.pkg.outputs.dir }}" ruff==0.15.20 pytest==8.4.2
(cd "${{ steps.pkg.outputs.dir }}" && ruff check . && python -m pytest -q)

- name: Build sdist + wheel
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: python

on:
push:
branches: [main]
paths:
- "sdk/python/**"
- "sdk/openai/**"
Expand All @@ -20,6 +21,8 @@ on:
- "e2e/**/*.py"
- "ruff.toml"
- "protocol/**"
- ".github/workflows/python.yml"
workflow_dispatch:

jobs:
test:
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/silkd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
paths:
- "silkd/**"
- "protocol/**"
- ".github/workflows/silkd.yml"
pull_request:
paths:
- "silkd/**"
Expand All @@ -29,5 +30,5 @@ jobs:
working-directory: silkd
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ base: silkd-image ## base VM image against the local boot + silkd images
docker build -t sandbox-base:dev \
--build-arg BOOT_IMAGE=$(BOOT_IMAGE) \
--build-arg SILKD_IMAGE=$(SILKD_IMAGE) \
--build-arg INPUTS_HASH=$$(find os-image/base -type f ! -name Dockerfile -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | cut -d' ' -f1) \
--secret id=sandbox_install_agent,src=os-image/base/install-agent.sh \
-f os-image/base/24.04/Dockerfile os-image/base

Expand All @@ -99,4 +100,4 @@ python: base ## python flavor image on top of base
--build-arg BASE_IMAGE=sandbox-base:dev \
-f os-image/python/3.12/Dockerfile os-image/python

images: base python ## all VM images
images: base python ## the local base and python images (CI builds every flavor)
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ performance) — source in
cocoon+guest) plus bare-metal acceptance drivers under `cmd/`: `demo`,
`smoke`, `meshsmoke`, `crossnode`, `coldproof`, `egresssmoke`,
`interceptsmoke`, `sockssmoke`, `volumesmoke`, `lifecycle` (idle→hibernate→archive),
`androidsmoke`, `browsersmoke`, `desktopsmoke`, and the `pullbench`/`pushbench`/`rpcbench`/`qaab`
perf drivers
`androidsmoke`, `browsersmoke`, `desktopsmoke`, `ringsmoke` (output ring cap, exec as a user),
and the `pullbench`/`pushbench`/`rpcbench`/`qaab` perf drivers
- `boot/kernel/` — kernel version pin (`VERSION` + matching tarball `SHA256`,
bump both together) + config fragment (amd64: over `x86_64_defconfig` +
`kvm_guest.config`; arm64: over `defconfig`, then `sandbox-arm64.config`)
Expand Down Expand Up @@ -159,6 +159,11 @@ Boot contract (cmdline keys consumed by sandbox-init):
| `sandbox.debug=1` | fatal errors drop to `/bin/sh` (debug initramfs) instead of poweroff |
| `sandbox.trace=1` | emit one pre-handoff line with per-phase µs timings |

The pinned kernel builds no 8250 UART, so on cocoon's Firecracker lane
(`console=ttyS0`) the trace line, the debug shell and the fatal-error
message all go nowhere; they are visible on the Cloud Hypervisor lane's
virtio console.

`boot=cocoon-overlay` is ignored. Everything cocoon passes today keeps
working — images built here boot with an unmodified cocoon.

Expand Down
2 changes: 1 addition & 1 deletion boot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ RUN set -e; \
case "$TARGETARCH" in \
amd64) make x86_64_defconfig kvm_guest.config; \
./scripts/kconfig/merge_config.sh -m .config /tmp/sandbox.config; \
arch_asserts="PVH VIRTIO_CONSOLE" ;; \
arch_asserts="PVH" ;; \
arm64) make defconfig; \
./scripts/kconfig/merge_config.sh -m .config /tmp/sandbox.config /tmp/sandbox-arm64.config; \
arch_asserts="RTC_DRV_PL031" ;; \
Expand Down
6 changes: 4 additions & 2 deletions docs/sdk-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,11 @@ zero.
- `APIError(verb, status, message)` — control plane (HTTP status)
- `SilkdError(kind, message)` — typed guest failure; `kind` is
`bad_request` (including a spawn the guest cannot start: missing binary, a
cwd that is not a directory, no exec bit) / `not_found` / `unimplemented` /
`internal`
cwd that is not a directory, no exec bit, a `user` the guest cannot
resolve) / `not_found` / `unimplemented` / `internal`
- `ExitError(code, stderr, stdout)` — non-zero exit from `exec`
- `SandboxTimeout` — a caller's timeout or deadline expired before the call
finished; also a `TimeoutError`
- `ProtocolError` — a failed relay dial or TLS handshake, or a broken stream
(EOF, oversized or undecodable frame)

Expand Down
3 changes: 2 additions & 1 deletion docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,8 @@ claims; the fields are those of [`GET /v1/sandboxes`](sandboxd-api.md#get-v1sand
- `*sandbox.ExitError` — non-zero exit from `Exec` (`Code`, `Stderr`)
- `*wire.ErrorResp` — a typed guest-side failure; `Kind` is one of
`wire.KindBadRequest` (including a spawn the guest cannot start: missing
binary, a cwd that is not a directory, no exec bit), `KindNotFound`,
binary, a cwd that is not a directory, no exec bit, a `User` the guest
cannot resolve), `KindNotFound`,
`KindUnimplemented`, `KindInternal` (import
`github.com/cocoonstack/sandbox/protocol/wire`)

Expand Down
2 changes: 1 addition & 1 deletion docs/silkd.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ A failed verb terminates with `error {kind, message}`:

| kind | meaning |
|---|---|
| `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable) |
| `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable), an exec or pty `user` the guest cannot resolve (`unknown user`, or `look up user: <errno>` when the name service itself fails) |
| `not_found` | unknown pid/session/path, a `port_forward` port nothing listens on, a language with no LSP manifest |
| `unimplemented` | verb unavailable on this lane — notably git clone/push/pull on the no-network lane, whose message points at `fs.push` |
| `internal` | everything else (other spawn failures, git errors, I/O) |
Expand Down
1 change: 1 addition & 0 deletions os-image/android/silkd.rc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ service silkd /system/bin/silkd
user root
group root
setenv SILKD_PORT 2048
setenv SHELL /system/bin/sh
# Mirrors the base:24.04 unit; forwarded into execs only on the no-NIC lane.
setenv http_proxy http://127.0.0.1:3128
setenv https_proxy http://127.0.0.1:3128
Expand Down
2 changes: 1 addition & 1 deletion os-image/desktop/24.04/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& install -d -m 0755 /etc/apt/keyrings \
&& curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \
| gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg \
&& echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \
&& echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \
> /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
&& rm -rf /var/lib/apt/lists/* \
Expand Down
2 changes: 1 addition & 1 deletion scripts/bench.sh
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ done
virt=$(systemd-detect-virt 2>/dev/null || true); virt=${virt:-unknown}
[[ $virt == none ]] && envlabel="bare metal" || envlabel="nested ($virt)"
cpu=$(awk -F': ' '/model name/{print $2; exit}' /proc/cpuinfo 2>/dev/null || sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)
digest=$(cocoon image list 2>/dev/null | awk -v t="$TEMPLATE" '$2 ~ t {print $3; exit}')
digest=$(cocoon image list 2>/dev/null | awk -v t="$TEMPLATE" '$2 ~ t {print $3; exit}' || true)

echo
echo "== results (paste into docs/benchmarks.md)"
Expand Down
4 changes: 3 additions & 1 deletion sdk/python/cocoonsandbox/conn.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def recv(self) -> dict[str, Any]:
"""Reads one frame or raises a typed protocol or guest error."""
try:
line = self._reader.readline(MAX_FRAME + 1)
except OSError as exc:
except (OSError, ValueError) as exc:
raise ProtocolError(f"read failed: {exc}") from exc
if not line:
raise ProtocolError("connection closed mid-stream")
Expand Down Expand Up @@ -159,6 +159,8 @@ def dial_agent(
ssl_context: ssl.SSLContext | None = None,
) -> Conn:
"""Opens one TCP/HTTP Upgrade relay within timeout and the optional deadline."""
budget = time.monotonic() + timeout
deadline = budget if deadline is None else min(deadline, budget)
for name, value in (("sandbox id", sandbox_id), ("token", token)):
if any(c in value for c in "\r\n\0"):
raise APIError("agent upgrade", 0, f"{name} contains a control character")
Expand Down
4 changes: 3 additions & 1 deletion sdk/python/cocoonsandbox/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import base64
import binascii
import json
import sys
from typing import Any

PROTO_VERSION = 1
Expand All @@ -14,6 +15,7 @@
FS_CHUNK = 256 * 1024
# tar and port streams chunk at 1 MiB: fewer frames per byte, still under MAX_FRAME after base64
BULK_CHUNK = 1 << 20
_FAST_DATA = sys.version_info >= (3, 11)


def encode_request(op: str, **fields: object) -> bytes:
Expand All @@ -34,7 +36,7 @@ def encode_request(op: str, **fields: object) -> bytes:
def decode_response(line: bytes) -> dict[str, Any]:
"""Decodes one response with binary payloads under data."""
# base64 is JSON-escape-free, so an exactly-shaped data frame slices without json.loads.
if line.startswith(b'{"type":"'):
if _FAST_DATA and line.startswith(b'{"type":"'):
te = line.find(b'"', 9)
if te > 0 and line[9:te] in (b"stdout", b"stderr", b"data") and line.startswith(b'","data":"', te):
de = line.find(b'"', te + 10)
Expand Down
49 changes: 48 additions & 1 deletion sdk/python/tests/test_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@
import pytest
from conftest import sandbox_at

from cocoonsandbox import APIError, Client, Lsp, ProtocolError, Pty, SandboxError, Session, SilkdError, Watcher
from cocoonsandbox import (
APIError,
Client,
Lsp,
ProtocolError,
Pty,
SandboxError,
SandboxTimeout,
Session,
SilkdError,
Watcher,
)
from cocoonsandbox.conn import Conn, dial_agent, remaining_timeout
from cocoonsandbox.frames import FS_CHUNK

Expand Down Expand Up @@ -151,3 +162,39 @@ def test_sandbox_timeout_is_both_hierarchies():
remaining_timeout(1.0, time.monotonic() - 1, "probe")
with pytest.raises(TimeoutError):
remaining_timeout(1.0, time.monotonic() - 1, "probe")


def test_recv_after_close_is_a_protocol_error() -> None:
a, b = socket.socketpair()
conn = Conn(a, a.makefile("rb"))
conn.close()
b.close()
with pytest.raises(ProtocolError):
conn.recv()


def test_dial_timeout_bounds_the_whole_upgrade() -> None:
server = socket.socket()
server.bind(("127.0.0.1", 0))
server.listen(1)

def slow_headers() -> None:
peer, _ = server.accept()
peer.sendall(b"HTTP/1.1 101 Switching Protocols\r\n")
for n in range(8):
time.sleep(0.15)
try:
peer.sendall(f"X-Slow-{n}: 1\r\n".encode())
except OSError:
break
peer.close()

threading.Thread(target=slow_headers, daemon=True).start()
addr = f"127.0.0.1:{server.getsockname()[1]}"
started = time.monotonic()
with pytest.raises(SandboxTimeout):
dial_agent(addr, "sb_x", "tok", timeout=0.2)
assert time.monotonic() - started < 0.6, (
"each header arrived under the per-read timeout; only a whole-dial budget stops this"
)
server.close()
Loading