Skip to content

feat(computer)!: fetch every install at put, build offline from the store, and declare each OS of an app with its own decorator - #402

Merged
earakely-scale merged 10 commits into
mainfrom
edgararakelyan/fd-4360-m12-the-install-format-fetch-at-put-install-from-the-store
Oct 30, 2026
Merged

earakely-scale merged 10 commits into
mainfrom
edgararakelyan/fd-4360-m12-the-install-format-fetch-at-put-install-from-the-store

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

M1.2 of the CUA env epic (FD-4345): the install format, fetched at put, FD-4360. It follows the design doc CUA Env M1.2: The Install Format, Fetched at Put, whose eight choices were decided on 2026-10-30.

Everything a computer installs now comes from files fetched once when the computer env is put. Builds never reach the internet.

What's in it

The format ([install.linux] in a driver's env.toml, Linux(...) in an app's class):

  • packages: the OS's packages, as apt takes them: name, name=version or name/suite.
  • sources: extra apt repositories (a URL, suite, components and a signing key in the layer's folder).
  • downloads: files fetched by URL and checked by sha256, for every arch or one, placed under the layer's prefix.
  • run: scripts of the folder, run as root or as the session user.
  • ports: those the layer listens on. M1.3 checks them against each other.
  • files, python and services are unchanged. An install that uses none of the new fields stores the same JSON as before.

Fetch at put (env/envs/computer/utils/packages.py, resolve_in_base.py):

  • For each arch, the composer resolves every layer's packages, requirements and downloads in a container of the base, with the build context read-only:
    • apt downloads each package with what the base doesn't already have;
    • pip turns each venv's requirements, and the protocol it adds, into wheels for the base's Python and arch;
    • downloads are fetched and their sha256s checked.
  • Every file goes in the object store by sha256 (agentenv-packages/files/), shared by every env and version.
  • A lock lists them. It's stored by a digest of its inputs: each layer's packages, sources, requirements file and downloads, the base, the arch and the resolver. Any env with the same inputs reuses it.
  • The env's metadata records which lock each arch used. Resolving runs only when an input changes.

Install from the store (the composer):

  • The build stages only the lock's files. A build that follows a resolve links the files that resolve just fetched, rather than downloading them back from the object store.
  • Every install step runs with RUN --network=none:
    1. the .debs, with apt's sources off;
    2. each venv, from its wheels with pip --no-index --no-deps;
    3. the run scripts;
    4. an app's @install_step methods.
  • Build-phase settings are set before the installs, so scripts and steps see them.
  • The build key gains the lock's digest, so it says exactly what an image installs.

The SDK (breaking: the PR title has a !):

  • One decorator per OS. An app declares each OS with its own decorator, replacing the linux = Linux(...) and launch = Launch(...) class attributes:
    • @linux(...) takes Linux's fields;
    • @install(os_type, spec) takes any OS type, a plugin's included, with that OS type's own install model.
  • What differs by OS goes in its decorator:
    • launch= always;
    • optionally skill= (a file), and settings= and build_settings=, whose fields add to the class's on that OS.
    • What's on the class applies to every OS.
  • Steps and checks by OS. @install_step(os=...) and @ready_check(os=...) run on every OS the app installs on unless they name some. Naming one the app declares no install for is refused when the class is read.
  • On the computer, the class reads what's declared for the OS it runs on (platform.detect()).
  • In the framework, everything that reads an app's settings, launch or steps asks for the computer's OS type.
  • Stored declarations change shape. Each OS's part sits under os.<type> (install, launch, skill, settings, build_settings), and ready checks are objects. An app env stored in the old shape still loads: AppDeclaration reads its top-level install and launch as its OS parts, and its ready checks by name.
  • A per-OS skill file can't sit beside an @agent_skill method. That's refused as a skill declared twice, since the app's server serves the method on every OS and nothing serves skill files until FD-4369.
  • A renamed module. The driver's Linux defaults module is now agentenv_protocol.computer._linux, since agentenv_protocol.computer.linux is the decorator.
  • The Linux model gains packages, sources, downloads, run and ports, with Packages, AptSource, Download and RunScript.
  • python -m agentenv_protocol.computer.run_install_step <file>:<Class> <method> runs an @install_step during the build. Steps were declared but never run until now.

Build settings for apps:

  • An app declares BuildSettings beside Settings: values its env fixes in its computer's image, as the base computer's build-phase settings are.
  • Its env.toml sets them (build_settings = { locale = "fr_FR" }), checked against the class's schema at put.
  • The composer writes them to .agentenv/build_settings.json in the app's folder, so they're part of the image's key, and a change builds another image from the same lock.
  • The app's install steps and its server both read them as self.build_settings. An install step has no self.settings: reading one raises AttributeError saying a deploy's settings aren't known at build.
  • A deploy that names one is refused: only another image changes it. A SecretStr build setting is refused at declaration, since the image would keep it.

Changed while building

  1. The step runner is run_install_step. A module named install_step would have replaced the package's install_step decorator once imported.
  2. The build context is read-only to the resolver, so a resolve can't change what the build key digests. A requirements file's own contents are an input to the lock; files it points at aren't.
  3. Build-phase settings come first in the Dockerfile, so run scripts and install steps see them.
  4. No emulation for the other arch fails it at the resolve stage when there's no lock yet, with the same reason as before.
  5. Review fixes (Greptile):
    • a file the resolver says it wrote must be a plain file in its output folder, never a link a dependency's build code left;
    • the resolver installs the apt packages it downloads before building wheels, so source builds get the tools and headers the install names;
    • a venv whose requirements resolve to no wheels is made empty;
    • a lock is written once, so two puts resolving the same inputs at once both build from the first;
    • run scripts are quoted.
  6. Review round (Edgar):
    • apps gained BuildSettings (above), so an install step reads values its env fixed rather than a half-made self.settings;
    • a build that follows a resolve stages that resolve's files from where it fetched them, so a put to S3 no longer uploads and then downloads every file it just fetched;
    • the test app resolves one package from a real Launchpad PPA (fastfetch, which Ubuntu's archive lacks), so a source's key and Signed-By are tested in CI.
    • a requirements file must list everything itself: a line including another file (-r, -c), an editable install, a local path or a local folder of wheels is refused at put, since the lock's inputs are that file alone and a local project can't build on the resolver's read-only context;
    • a build that stages a lock's files from the object store refuses one whose sha256 isn't the lock's;
    • the protocol README says how installs behave: apt without recommends, each venv exactly its resolved wheels (pip install --no-deps).
    • Follow-ups filed: FD-4734 (compose on a sandbox provider, next) and FD-4735 (private package indexes).
  7. Maintainability round (Edgar):
    • Packages, AptSource, Download and RunScript are defined once, in the protocol, with their checks; the framework imports them. An app's bad value now fails when its class is defined, and the declare step names the field (Download.sha256: …).
    • The lock is a model (Lock and its locked files), validated when read from the store.
    • The staged paths are spelled once, in packages.
    • A lock's inputs hash the request the resolver is given, the resolver script and the lock's shape, instead of packages.py's source. Editing packages.py no longer re-resolves every lock, and the owner's uid stays out of the inputs.
    • missing() and unlocked() are one folder_problems().
  8. The app API, per OS (Edgar, four decisions):
    1. one decorator per OS, plus a generic @install for plugin OS types;
    2. os= on install steps, defaulting to every OS;
    3. launch, and optionally skill, settings, build settings and ready checks, per OS;
    4. in this PR. The no-migration choice was later reversed after review: old stored apps are read.

Testing

  • Unit: tst/unit and the protocol tests passed 11,822, with 66 skipped. One Hermes test fails on macOS only, as before, since it reads /proc/self/fd.
  • New and extended tests:
    • the format's fields and its refusals: a missing script, an apt entry that isn't one word, sources with no packages, a bad sha256, a port out of range;
    • the SDK's declaration of the new fields, and the step runner;
    • the composer:
      • what's resolved and staged, and the Dockerfile's offline steps in order, with run scripts and an install step as root and as the session user;
      • a download for one arch only;
      • a resolve or a download that fails, recorded at the resolve stage;
      • a second env reusing the stored lock without resolving.
  • Integration, on Docker: the test app (tst/integration/computer/app) now takes an apt package (hello), a download checked by sha256, a run script that uses the package, and an install step as the session user. The test checks all four on the deployed computer.
  • After the review fixes: the composer tests passed 98, and the test app's integration run passed again, re-resolving with apt installing inside the resolver.
  • After the review round (33d6be1):
    • Unit: tst/unit and the protocol tests passed 11,833, with 66 skipped (11,851 on 83abf63, after the requirements and sha256 checks; 11,853 on 80e81a1; 11,860 on b5b089b, the per-OS app API). The test app's integration run passed again on each, the last with a Linux-added setting, a Linux-only install step and ready check. Besides the macOS-only Hermes failure, one gateway timing test failed under the laptop's Docker load and passed alone; this PR doesn't touch it.
    • Integration on Linux VMs (computer tier, cua-driver included):
      • arm64 (c7g, native, no emulation): 23 passed, 1 skipped (model_endpoint_configured);
      • amd64 (c6i, arm64 emulated): see below.
    • Integration on this laptop: the test app's run passed, with fastfetch from the PPA (2.70.0~noble) and its build setting reaching both the install step and the server.
    • Every install form at once (a one-off app, run on this laptop and on the arm64 VM):
      • hello=2.10-3build1 was installed at that version;
      • libreofficekit-data/noble-backports came from backports (~bpo24.04.1);
      • gh came from GitHub CLI's repository, whose binary keyring holds an expired key beside the current one;
      • zeek-btest came from a flat OBS repository (suite = "/", armored key);
      • netifaces 0.11.0, published only as source, was built with the gcc and Python headers apt installed;
      • jq was downloaded for each arch and made executable;
      • run scripts ran as root and as the session user, and found the network closed;
      • install steps ran as root and as uid 1000 and read the build setting, and reading self.settings raised the new error.
    • Release mode:
      • wheels were built as the release builds them: the protocol at a version PyPI lacks (0.1.404.dev402), the framework pinned to it, and the shipped drivers' requirements pinned by shipped_bundles.py pin;
      • they were installed in a fresh venv, with the protocol served from a local index that the base's pip was pointed at;
      • the test app's run passed, both arches' venvs held 0.1.404.dev402, and every resolve fetched it from that index.
    • S3 object store (a private bucket, deleted after):
      • the computers, drivers and test app bundles were registered with agent-env bundle register;
      • the first put staged 0 files from S3, linking what its resolve had fetched;
      • a second version that changed only the build setting reused both locks, staged 136 files from S3, built new images under new keys, and took 53 s.
  • On this laptop (arm64, amd64 emulated):
    • First put (112 s): both arches resolved and built. Each lock has hello 2.10-3build1, 65 wheels across the reference driver's and the app's venvs, and the download; the store holds 44 unique files for both. arm64 was ready after its probe; amd64 built but unverified.
    • A second version that changed only the app's skill (43 s): it reused both locks without resolving, and only the image rebuilt.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new actionable issues remain from this review.

Summary

The PR fetches install files at put, builds offline from stored locks, and adds per-OS app declarations and build settings.

  • Computer images install packages resolved and saved when an environment is put.
  • Apps can declare different installs, launches, and settings for each operating system.
  • App install steps run during image builds with the app's fixed build settings.

Reviews (10) · Last reviewed commit: "fix(computer): join a requirements line ..." · Reviewed by Greptile

… store

The install format for drivers and apps gains packages (as apt takes them),
sources (extra apt repositories with a signing key), downloads (a URL and
sha256 per arch), run scripts (as root or the session user) and ports.

At put the composer resolves every layer's packages, Python requirements and
downloads for each arch in a container of the base, stores each file in the
object store by sha256, and stores a lock listing them by a digest of its
inputs, which any env with the same inputs reuses. A build stages only the
lock's files and installs them with RUN --network=none: the .debs, each venv
from its wheels, the run scripts, then an app's @install_step methods, which
python -m agentenv_protocol.computer.run_install_step now runs. The build key
gains the lock's digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 2 commits October 29, 2026 19:06
…s first-written

- A file the resolver says it wrote is refused unless it's a plain file in its
  output folder: build code a dependency runs in the container could leave a
  link to one of this machine's files, which put would have uploaded.
- The resolver installs the apt packages it downloads before building wheels,
  so a package built from source has the build tools and headers the install
  names.
- A venv whose requirements resolve to no wheels is made empty, with nothing
  copied or installed.
- A lock is written once: a put that resolved the same inputs at the same
  time uses the lock the first one stored.
- Run scripts are quoted, so a name with a space runs as one script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…from where it fetched them

An app's BuildSettings are values its env.toml fixes in its computer's image (build_settings), checked at put, written
to .agentenv/build_settings.json in its folder, read as self.build_settings by its install steps and its server, and
refused at deploy. An install step no longer sees a half-made self.settings: reading one raises AttributeError naming
self.build_settings. A build that follows a resolve links the files it just fetched instead of downloading them back
from the object store. The test app now resolves one package from a Launchpad PPA and installs on a build setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SecretStr in a list or in a nested model put its writeOnly under items or $defs, where the check of each field and
its anyOf options didn't look, so it would have been written into the image. The whole schema is searched now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keeps subschemas

Searching every dict of the schema read a field named writeOnly, or a default holding that key, as a secret's marker.
The search now follows only the keywords that hold subschemas: properties, items, $defs and the like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

Results from the Linux VMs (agent-environments-admin, at d5995611, whose code matches 33d6be1 except for docstrings; the two later commits change only how declaration() finds a secret in BuildSettings):

  • Computer integration tier, cua-driver included:
    • arm64 (c7g, native, no emulation): 23 passed, 1 skipped (model_endpoint_configured).
    • amd64 (c6i, native, with arm64 built under emulation): 23 passed, 1 skipped (model_endpoint_configured).
    • On both, the two cua-driver tests passed natively, and so did the test app with its package from the Launchpad PPA.
  • The every-install-form app, on both VMs, gave the same results as on the laptop: amd64 ready on its VM, arm64 ready on its own.
    • hello=2.10-3build1 and libreofficekit-data/noble-backports installed as named.
    • gh came from GitHub CLI's repository (binary keyring), and zeek-btest from a flat OBS repository.
    • netifaces 0.11.0 was built from source with apt's gcc.
    • jq was downloaded per arch and made executable.
    • Run scripts and install steps ran as root and as the session user, offline, on the build setting.

On 607ffae, CI passed 24/24 and Greptile gave 5/5. The VMs are terminated.

🤖 Generated with Claude Code

…aged files' sha256

A lock's inputs are a requirements file's own bytes, so a line including another file (-r, -c), an editable install,
a local path or a local folder of wheels would let an edit to that file reuse a stale lock; a local project also can't
build on the resolver's read-only context. Put refuses such a line now, naming it. A build that stages a lock's files
from the object store refuses one whose sha256 isn't the lock's. The protocol README says how installs behave: apt
without recommends, each venv exactly its resolved wheels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 2 commits October 29, 2026 22:03
… inputs that ignore edits that resolve nothing

- Packages, AptSource, Download and RunScript are defined once, in the protocol, with their checks; the framework's
  Install imports them, and an app's bad value fails as its class is defined, the declare step naming its field.
- The lock is a model (Lock and its locked files), validated when read from the store.
- The staged paths are spelled once in packages (STAGED_APT, staged_wheels) and read by the composer.
- A lock's inputs hash the request the resolver is given, the resolver script and the lock's shape instead of
  packages.py's source, so editing packages.py no longer re-resolves every lock; the owner's uid stays out of them.
- missing() and unlocked() are one folder_problems().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app declares how each OS installs and launches it with a decorator per OS: @linux(...) with Linux's fields, or
@install(os_type, spec) for any OS type, a plugin's included, with that OS type's install model. What differs by OS goes
in its decorator: launch always; optionally a skill file, and settings and build settings whose fields add to the
class's on that OS. @install_step(os=...) and @ready_check(os=...) run on every OS the app installs on unless they name
some; naming one it declares no install for is refused when the class is read. On the computer the class reads what's
declared for the OS it runs on (platform.detect()), and everything in the framework that reads an app's settings,
launch or steps asks for the computer's OS type.

BREAKING CHANGE: the class attributes linux = Linux(...) and launch = Launch(...) are gone, and an app's stored
declaration keeps its per-OS part under os.<type> (install, launch, skill, settings, build_settings), with ready checks
as objects. An app env stored before this must be put again. The driver's Linux defaults module is now
agentenv_protocol.computer._linux, since agentenv_protocol.computer.linux is the decorator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale earakely-scale changed the title feat(computer): fetch every install at put and build offline from the store feat(computer)!: fetch every install at put, build offline from the store, and declare each OS of an app with its own decorator Oct 30, 2026
earakely-scale and others added 2 commits October 30, 2026 08:02
…n part, and refuse a per-OS skill beside a method

An app env stored before the per-OS shape loads again: AppDeclaration reads its top-level install and launch as its
OS parts, and its ready checks by name. A per-OS skill file on a class whose skill is an @agent_skill method is refused
as a skill declared twice, since the app's server serves the method on every OS and nothing serves the file yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fore checking it

A remote --find-links split over two lines was refused as naming a local folder, since its first line holds no URL.
The check now reads the file as pip does, joining each line that ends in a backslash to the next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 10f062e into main Oct 30, 2026
25 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-4360-m12-the-install-format-fetch-at-put-install-from-the-store branch October 30, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant