Skip to content

Commit fc23baf

Browse files
authored
feat(craft): sandbox outputs manifest (#14325)
1 parent c527de7 commit fc23baf

11 files changed

Lines changed: 639 additions & 1 deletion

File tree

backend/onyx/server/features/build/sandbox/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
ToolCallProgress,
3333
ToolCallStart,
3434
)
35+
from onyx.server.features.build.sandbox.image.sandbox_daemon.contract import (
36+
OutputsManifestResponse,
37+
)
3538
from onyx.server.features.build.sandbox.models import (
3639
CraftLLMProviderConfig,
3740
CraftMCPServerConfig,
@@ -471,6 +474,19 @@ def list_directory(
471474
"""
472475
...
473476

477+
@abstractmethod
478+
def get_outputs_manifest(
479+
self, sandbox_id: UUID, session_id: UUID
480+
) -> OutputsManifestResponse:
481+
"""Describe the session's outputs tree in one call.
482+
483+
Symlinks and non-regular files are counted, never followed. Regular
484+
files carry size, mtime, and a content hash when under the hash
485+
ceilings. A missing outputs tree is an empty manifest, not an error.
486+
Raises RuntimeError-family errors when the backend cannot answer.
487+
"""
488+
...
489+
474490
@abstractmethod
475491
def read_file(self, sandbox_id: UUID, session_id: UUID, path: str) -> bytes:
476492
"""Read a file from the session's workspace.

backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@
112112
stream_stdin_to_container,
113113
stream_stdout_from_container,
114114
)
115+
from onyx.server.features.build.sandbox.image.sandbox_daemon.contract import (
116+
OutputsManifestResponse,
117+
)
115118
from onyx.server.features.build.sandbox.labels import (
116119
LABEL_K8S_MANAGED_BY,
117120
LABEL_K8S_MANAGED_BY_ONYX,
@@ -1621,6 +1624,32 @@ def list_directory(
16211624
entries = self._parse_ls_output(output, clean_path)
16221625
return sorted(entries, key=lambda e: (not e.is_directory, e.name.lower()))
16231626

1627+
def get_outputs_manifest(
1628+
self, sandbox_id: UUID, session_id: UUID
1629+
) -> OutputsManifestResponse:
1630+
container = self._require_container(sandbox_id)
1631+
try:
1632+
# Root-owned interpreter and module: the sandbox user owns
1633+
# /workspace, so anything under it could be swapped to lie.
1634+
# workdir=/opt is load-bearing, python -m imports the root-owned
1635+
# /opt/sandbox_daemon only because cwd leads sys.path. -E -s
1636+
# ignore PYTHON* env vars and the user site directory.
1637+
result = _run_in_container_as_sandbox_user(
1638+
container,
1639+
[
1640+
"/usr/local/bin/python3",
1641+
"-E",
1642+
"-s",
1643+
"-m",
1644+
"sandbox_daemon.manifest",
1645+
str(session_id),
1646+
],
1647+
workdir="/opt",
1648+
)
1649+
except ExecError as e:
1650+
raise RuntimeError(f"Failed to build outputs manifest: {e}") from e
1651+
return OutputsManifestResponse.model_validate_json(result.stdout_text)
1652+
16241653
def _parse_ls_output(self, ls_output: str, base_path: str) -> list[FilesystemEntry]:
16251654
entries: list[FilesystemEntry] = []
16261655
for line in ls_output.strip().split("\n"):

backend/onyx/server/features/build/sandbox/image/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# syntax=docker/dockerfile:1-labs
12
# Sandbox Container Image
23
#
34
# User-shared sandbox: one pod per user, sessions created via kubectl exec.
@@ -186,6 +187,15 @@ ENV PATH="/home/sandbox/.opencode/bin:${PATH}"
186187
ENV OPENCODE_DISABLE_MODELS_FETCH=1
187188

188189
COPY --exclude=__pycache__ sandbox_daemon/ /workspace/sandbox_daemon/
190+
# Root-owned copy for exec-based backends: the manifest must run code the
191+
# sandbox user cannot replace. /workspace stays sandbox-owned below. The
192+
# contract model needs pydantic, so the root-owned system interpreter gets its
193+
# own copy (version pinned to match initial-requirements.txt): the exec path
194+
# must share nothing with the sandbox-owned venv.
195+
COPY --exclude=__pycache__ sandbox_daemon/ /opt/sandbox_daemon/
196+
RUN /usr/local/bin/python3 -m pip install --no-cache-dir --only-binary=:all: \
197+
pydantic==2.13.4 && \
198+
rm -rf /root/.cache/pip
189199
COPY entrypoint.sh /workspace/entrypoint.sh
190200
COPY sidecar-entrypoint.sh /workspace/sidecar-entrypoint.sh
191201

backend/onyx/server/features/build/sandbox/image/sandbox_daemon/contract.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
SIDECAR_PUSH_PATH = "/push"
1616
PUSH_DAEMON_PORT = 8731
1717
SIDECAR_FILESYSTEM_LIST_PATH = "/filesystem/list"
18+
SIDECAR_OUTPUTS_MANIFEST_PATH = "/filesystem/outputs-manifest"
1819
SIDECAR_SNAPSHOT_CREATE_PATH = "/snapshot/create"
1920
SIDECAR_SNAPSHOT_RESTORE_PREFIX = "/snapshot/restore"
2021
SIDECAR_SNAPSHOT_RESTORE_ROUTE = f"{SIDECAR_SNAPSHOT_RESTORE_PREFIX}/{{session_id}}"
@@ -34,6 +35,9 @@ class SnapshotCreateRequest(BaseModel):
3435
session_id: UUID
3536

3637

38+
# Restore has no response body. Failures raise, success is the 204.
39+
40+
3741
class FilesystemListRequest(BaseModel):
3842
model_config = ConfigDict(extra="forbid")
3943

@@ -57,4 +61,28 @@ class FilesystemListResponse(BaseModel):
5761
entries: list[SidecarFilesystemEntry]
5862

5963

60-
# Restore has no response body — failures raise, success is the 204.
64+
class OutputsManifestRequest(BaseModel):
65+
model_config = ConfigDict(extra="forbid")
66+
67+
session_id: UUID
68+
69+
70+
class OutputsManifestEntry(BaseModel):
71+
model_config = ConfigDict(extra="forbid")
72+
73+
path: str
74+
is_directory: bool
75+
size: int | None = None
76+
mtime_ns: int | None = None
77+
# None for directories and for files past the hash ceilings.
78+
sha256: str | None = None
79+
80+
81+
class OutputsManifestResponse(BaseModel):
82+
model_config = ConfigDict(extra="forbid")
83+
84+
entries: list[OutputsManifestEntry]
85+
skipped_symlinks: int = 0
86+
skipped_special: int = 0
87+
skipped_unreadable: int = 0
88+
truncated: bool = False

0 commit comments

Comments
 (0)