Projects and execution targets
PostgreSQL authority
DB-backed Policy.resolveAccess() returns current Project scope, host roots, executable Project ids and host capability together for one descriptor or listing. resolvePolicy intersects single-statement authority facts with its captured ceiling; a ceiling captured as non-admin still requires explicit membership after promotion. Deleted/deleting Projects are not executable, and computers always require membership. Every new operation reads again: this result cannot authorize later effects after a wait. Static policies and delegated administrator wrappers without the capability retain their existing live accessors.
Target preparation awaits the existing transcript, journal, users and device repositories. journal.hasPausedQueue(sessionId) performs the bounded recovery check before a target change. Device lookup carries the contribution account and native platform/shell metadata; bindLocalTurn is awaited before the caller receives a releasable binding. Preparation runs under the existing session lock, commits durable selection before publication and adds no connection or recovery worker. BrainService, lifecycle and status projection consume the same async owners.
ProjectsRepository.defaultProjectRef passes its transaction-bound repositories to the candidate
authorization callback. personalProject.defaultProjectRef passes their project and membership readers
to Policy.canAccessProject(projectId, current), so live ACL reads reuse the held connection. The policy
keeps the ceiling captured by resolvePolicy; rebinding readers never recaptures or widens it. Ordinary
authorization omits current and uses the policy's process repositories. New personal conversations and
offline-local fallback both use this selection path, including with a one-connection pool.
selectableProjectTargets reads the full Project list once and reuses one fresh policy evaluation and host-root set for that listing. Host targets validate their already-listed paths directly; no separate per-target Project lookup remains. Dispatch and later operations retain their live rechecks.
During an admitted turn, Project awaits LiveBrain.rebindLocalExecution before the execution-target SQL transaction. A computer disconnecting between metadata resolution and binding rejects selection without changing the durable target or working directory. The existing serializable compare-and-swap still validates session authority before committing the target and plan intents.
A change involving any non-host target records its plan move in that same transaction, including host-to-local and local-to-local changes. bindChannelContext records a local plan re-key with session reassignment, so the existing plan-operation drain moves the file to the conversation's new ID.
Durable Project deletion
ProjectService.remove is the one deletion admission path for host, managed and paired-computer Projects. GET /projects/:id/deletion?deleteFiles=true|false returns the effective choice and exact paths, participant-owned items, unavailable owners and current durable operation. DELETE /projects/:id requires {deleteFiles, confirmedPaths, confirmedItems, confirmedPreservedPaths} from that preview and returns 202 with {deletion}, not a completed deletion. Preview defaults to deleteFiles: false; DELETE requires a real boolean and rejects omission rather than guessing. The checkbox is initially unchecked. A pending operation returns its stored choice even if another preview choice was requested, and changing it in DELETE is refused. The Project tool uses the same preview and confirmation with an optional deleteFiles argument defaulting to false. Adopted/pre-existing paths are included only when working-directory deletion is selected. preservedPaths lists directories retained because they are protected or overlap another live Project, with a localized explanation in the confirmation; the Project and its bound resources are still removed.
Admission first commits an inactive Project and a blocked confirmation checkpoint. A second description after that fence must match the confirmed scope before execution is released; newly discovered paths or resources return project_deletion_scope_changed and require confirmation again. A crash during this interval leaves a visible blocked Project, not permission to destroy an unconfirmed resource. Missing plugins and offline computers retain a named blocked obligation; restoring them and explicitly retrying reopens confirmation.
The daemon's existing maintenance owner starts durable deletion dispatch after plugins settle, before recovery resume dispatch, without waiting for all external cleanup to finish. Pending operations continue on its existing one-second tick; blocked operations owned by another step whose runtime checkpoint is still missing also stop producers and acknowledge that checkpoint without releasing destructive steps. Each completed participant is acknowledged before advancing: stop runtime producers, service plugins, managed storage plugins, optional files, then final SQL. deleteFiles: false omits the files participant entirely and performs no working-directory resolution or file operation; all service/storage participants still run, so a managed disk always goes with its Project. Computer publication release and disconnect run before final SQL regardless of the file choice. A rejected step blocks without continuing to disks. A crash after a side effect but before its acknowledgement repeats that idempotent participant. Sandbox is a storage executor of this operation, not a second Project-deletion authority; published Sites retire through their existing durable delete before Sandbox removes storage. Domains detach without DNS or registrar changes.
The confirmation paths include plugin-owned storage and absolute canonical core targets. Private files JSON stores typed {path, canonicalPath, preserve} receipts through beginDeletion(id, {requestedByUserId, deleteFiles, paths, files}) and retryDeletion(id, paths, files); deletionFiles(id) reads them. The immutable delete_files boolean is stored beside these receipts; unchecked operations have no working-directory deletion targets. Source paths retain their confirmed canonical identity across restart. Resolution changes block deletion; mutations use the canonical target, never the source alias. A null canonical target records an offline obligation and cannot authorize deletion. Preserved receipts never become destructive after another owner disappears. Core never recursively removes plugin-reported host storage; plugins keep their own ownership checks.
Host classification uses the existing canonical path resolver and the explicit daemon instance root. The OS home, daemon checkout, filesystem root and their ancestors are retained; instance data and client installation trees are retained in either containment direction. A host directory equal to, inside or containing another existing host Project's working directory or managed Project's adopted host path is retained, including symlink aliases. The dispatcher rechecks before removal and requires renewed confirmation if another Project acquired the directory. Paired-computer classification and canonical inspection use CliConnections.describeProjectDeletion over the existing file-stat transport; home, root, shared .elowen, client installation and their ancestors are protected. Paths on separate computer targets are not compared as if they were on the same filesystem; a target is bound to exactly one Project.
For a paired-computer Project, remote filesystem cleanup deletes only its captured, explicitly confirmed working directory, including a pre-existing/adopted directory. Shared ~/.elowen/plans, ~/.elowen/tool-results and ~/.elowen/workflow-results are outside this cleanup scope; their current and historical contents remain. This root has no target or instance namespace and can serve other Projects paired under the same OS account. Users can delete its contents manually after checking that no other Project needs them. Deletion adds no remote inventory, namespace or client protocol change. When working-directory deletion is selected, an offline computer keeps its pairing and durable blocked file obligation until reconnection and an explicit retry. Keeping files requires no remote filesystem access.
Core journal admission locks users, all referenced Projects, then session identities/rows before writing. Both plan endpoints, delegated scopes, running calls/workflows and workflow resume require active targets. Boot claims omit deleting parent/child targets, and actual recovery-attempt admission rechecks after claim, without spending an attempt if deletion intervened. These checks apply while a blocked deletion still retains its Project row; unrelated recovery and terminal settlement continue.
Plugin-owned writes use db.transaction(async tx => { await tx.host.projects.requireActive(projectId); ... }) before their final SQL write. This existing host-store facet holds a shared Project row lock until commit and rejects inactive/missing rows. Core admission takes the conflicting update lock. Network work stays outside transactions; an existing atomic filesystem owner's final local write may run under that lock only in a short, non-retrying READ COMMITTED transaction. The call is an admission fence, not account authorization; existing tenancy checks still apply. Without a transaction the lock cannot protect a later write.
Ordinary conversation history and accounting survive. Finalization rebinds eligible human conversations, detaches chatbot conversations without moving their account, terminalizes delegated work and consumes restart authority. Shared Project memory is deleted; personal memory is detached. The last usable Project rule remains for human non-administrators only. See persistence for the lock order and retained data.
Interactive managed terminals
Sandbox owns interactive openShell admission for managed Projects as well as host Projects. Bundled Terminal supplies the Project reference and window size from a signed-in web request; Sandbox reloads Project membership, then uses the existing managed readiness, authorization and lease machinery. A running container receives a digest-addressed isolated Python PTY helper through the privileged socket-stdio transport. Its lifetime:'connection' session uses a dedicated 32-terminal pool, independently of ordinary agent executions and guest file agents. There is no runtime deadline; lease revocation and socket loss still cancel the full guest unit.
The shared elowen-plugin-shared/pty module owns encodePtyStart: type 0x03, bounded JSON payload {argv,cwd,env,cols,rows}, accepted once before the managed helper forks its shell. The helper receives the eligible account credential over stdin, while the privileged request has an empty environment and a fixed cwd. Subsequent frames are data or resize only. Output goes through the existing credential stream sanitizer. Missing helper bytes or digest drift refuse preparation. See plugin execution for the consumer contract and deployment for root installation.
Managed shell cancellation retains a rejected closed promise and the unknown execution outcome; it is not a successful exit. The worker client's explicit session.cancel() supplies an InstanceConditionError cause. Sandbox and Terminal preserve that original failure in log metadata, so sandbox.shell_failed and terminal.exit_unknown stay local for a requested close while protocol, transport and cleanup defects remain reportable. This uses the existing problem classifier and adds no retry or wire field.
Connected CLI targets
ProjectExecutionRef has three strict shapes, and all three name a Project: {kind:'host',projectId?}, {kind:'managed',projectId} and {kind:'local',projectId}. A local ref names a computer Project (projects.execution_kind 'local'), whose computer_target_id resolves the paired device; the ref never carries a device id, so a conversation's target is a Project like any other and survives a client that re-pairs.
projects.createComputer writes that Project with its device token in one transaction. Its slug is computer-<device>-<target digest>, with the first free numeric suffix when an ordinary Project already holds that exact slug, and existing Projects are never renamed. It is appended after the account's complete effective order, materialized first so an unranked Project cannot end up behind it, and it never counts toward the managed project limit. Migration 0004_computer_projects allocates the same way for devices paired before the upgrade, rewrites every stored local ref to its projectId, and settles plan operations naming a device that no longer exists.
Finalizing explicit Project deletion through projects.finishDeletion settles every pending plan operation whose source or destination names that Project, for host, managed and computer execution alike. The vanished endpoint has no plan location left to reach, so the intent is dropped and later moves in the conversation chain can drain. Other Projects' operations remain. Migration 0007_orphaned_local_plan_operations applies the same rule to existing local intents whose Project row is missing; it leaves host and managed intents alone. The transaction preserves C5-098's user/project/session lock order before deleting plan intents. journal.setProjectExecution takes those same locks for every Project named by its new ref or either plan-intent endpoint and refuses a missing or deleting row before writing anything. BrainService maps this typed ExecutionTargetUnavailableError to ProjectSwitchRefusedError, so removal after target resolution cannot recreate an orphan and an agent-requested switch remains a normal refusal. The HTTP selection route returns 409.
Canonical computer observation and visibility
computerConnectionState(observation) in src/projects/projectService.ts is the
shared mapper for Project list/detail and conversation execution metadata.
CliConnections.connectionState(targetId) owns the raw observation: real target/
Project, socket authority UUID, numeric generation/sequence and epoch-ms observedAt
pass through unchanged. Transport is connected/disconnected, authority is
server-observation and freshness describes that socket fact. Readiness is unknown
and retry is null: the registry cannot observe a disconnected device's attempt
series or application readiness. Missing observation maps to null, never Offline
inferred from device load timestamps, a failed query or an invented countdown.
Project and conversation consumers read the latest observation after awaited metadata and derive connected from that same fact. Metadata/query failures propagate. A missing registry observation does not invent transport authority. Project lists recheck access after projection and omit revoked local Projects; detail refuses 403. Conversation execution resolves Sandbox under its contribution account but independently checks the authenticated viewer's explicit membership. Administrator transcript oversight does not grant computer membership. Revocation before/during/after a snapshot removes local projectRef, cwd, branch, connected and connectionState, without removing the authorized transcript/model/title.
Real consumers are Project list/detail, BrainStatusService.executionFor and its
scoped status/read-only/stream snapshots. This is observation only, not executor
admission, a remote Retry action, policy distribution or a UI change. It adds no
poller or device request; work scales with existing projected Projects and current
membership reads. Independent control lanes are described in
conversation activity.
Working on a computer
A computer is private to its explicit Project members, including its pairing owner. UserProjectsRepository.canAccess(userId, projectId) owns active visibility and canExecute delegates to it; ordered applies the same membership rule when listing computers. Administrators retain oversight of host and managed Projects, but cannot see or target another account's unshared computer. Members work concurrently through the pairing owner's elowen connect socket, under their own permission rules. Policy.canExecuteProject applies the same membership requirement within the turn's captured ceiling. Direct local execution by a non-member answers local_target_forbidden (paired computers).
The computer's creator_user_id alone receives management authority from UserProjectsRepository.canManage. addMember and removeMember use that same authority even when the caller is an administrator assigned as a member. Renames, metadata changes and disconnects also require it. This is the computer exception to the 22 August 2026 administrator-can-reach-everything contract: administrator rights on host and managed Projects are unchanged. Direct device-token revocation and account deletion refuse while the account still owns paired computers: their Projects must complete explicit, confirmed deletion first. Account deletion removes only memberships on computers owned by another account.
GET /projects?view=assignable narrows the normal list through canManage for administration pickers; GET /users/:id/projects applies the same filter to assignment rows and counts. Ordinary GET /projects remains the computer list for work, so explicitly shared administrators can use a computer without administering it. There is no separate local-target listing control or device-list route. ProjectView.computer.load uses CliDeviceLoad, including its daemon receive timestamp, nested CPU reading and named memory/disk byte fields. The shared browser computer rings consume this same snapshot without changing their percentages, labels or tooltips.
The picker and the agent's Project tool list a computer once, from selectableProjectTargets, with computer: {label, connected} so a client shows it by name and disabled while it is offline; a runner holds no socket map, so it lists none. listSwitchableProjects no longer merges a separate local-target list: GET /brain/execution answers computer rows as ordinary Projects with their computer facts. Default selection (personalProject.defaultProjectRef) takes a computer only while the account may execute there AND it is connected, and the next Project in the order wins otherwise; hosts and managed Projects are unaffected, and a computer never satisfies the last-Project invariant that keeps a non-admin account from being unassigned. Switching uses the existing between-step boundary.
No pass moves a conversation off its computer on the daemon's own initiative: an unreachable computer
never hands the conversation to a server target. An offline one answers LOCAL_TARGET_OFFLINE, and
BrainPauseRecovery.parkComputerContinuation records a native computer_unavailable prerequisite under
the session lock, keeps the selected target and its recorded directory, and checkpoints the input queued
behind the turn. The conversation waits there until the authenticated executor reconnects and
ComputerPrerequisiteCoordinator resumes it; returning elsewhere requires an explicit Project switch
(restart recovery). A computer whose device is
revoked under a live conversation instead fails the turn with the typed local_target_unavailable.
Later starts and sends also ignore a paired computer's reported cwd for host and managed targets.
A top-level owner turn parked by daemon restart keeps its selected computer and recorded directory. Recovery silently continues the checkpointed tail after the canonical interrupted-tail compiler supplies each unfinished computer tool call with an error result carrying interrupted:true and mutationOutcome:'unknown'; the text tells the model to verify the computer's state before repeating the operation. No dispatched executor operation is replayed automatically. If the computer is offline, the durable park and paused user queue remain without consuming an attempt. Nothing applies a server fallback while that park stands. The authenticated executor's onConnected(identity) callback hands the Project to ComputerPrerequisiteCoordinator.wakeComputer, which claims each durable prerequisite for the live connection and resumes the owner conversation through BrainService.resumeComputerConversation; boot and reconnect serialize per conversation and recheck the claim's continuation_generation before the first provider dispatch, so the user's own admission or Stop wins without a duplicate continuation. Later steers remain in the running continuation through the normal PI queue. The marker survives a successful answer while its paused queue still awaits replay, so disconnecting before replay leaves a durable reconnect or boot wake without recomputing that answer. A second daemon restart discovers the same durable wait. A delegated call or workflow node on a paired computer parks on the same coordinator (parkDispatchedComputerCall, resumeComputerWorkflowNode) instead of terminalizing, and resumes through its own owner when that connection returns.
Removing the computer itself has no target left to retarget from, so the move is announced from the deletion.
projects.finishDeletion rebinds an ordinary human non-administrator's conversation to the next usable Project in their order (administrator and chatbot conversations are left without one), clears brain_sessions.work_dir because that directory was a path on
that machine and must never be reopened as a path here, and notifies the process with the removed computer's
own {kind:'local',projectId} reference. BrainService records the ordinary cwd chat event for it, naming
the destination through executionNoticeDetail: the rebound Project's slug or path, else the daemon's host
directory. The device and its name went with the Project, so that notice cannot name the computer; a
delegated child keeps its captured scope and is not moved. For a restart-parked top-level owner turn, that same removal transaction first marks activity failed with a reason naming the removed computer Project, clears its park and discards its paused queue. A stale boot item cannot continue that interrupted turn on the rebound server target.
A running turn keeps its target, and a connection lost mid-turn parks that turn instead of failing it:
withTurnExecution's park path checkpoints the conversation and leaves it waiting for the computer, while
the LOCAL_EXECUTION_LOST mutation outcome stays unknown and no dispatched operation is replayed. Opening a
conversation and passive history reads stay off the computer entirely, and a read-only snapshot changes no
target. New conversations explicitly selecting an offline computer remain a typed LOCAL_TARGET_OFFLINE
refusal. A missing local directory remains LOCAL_WORKDIR_MISSING and never changes the target.
Read projections authorize the transcript before resolving execution metadata. BrainStatusService.localLocation looks up the computer under the delegated contributionUserId, or the conversation owner's user_id, even when an administrator views another account's history. If revocation removed the device metadata, only the typed local_target_unavailable failure becomes an unavailable location: the snapshot retains its local ref and recorded or delegated cwd, reports connected:false, and performs no branch read. Other projection failures still propagate. This observational result grants no execution authority; resolveLocalTarget remains a refusal for an unknown or revoked device.
effectiveTurnWorkDir resolves local startup cwd and platform metadata through SandboxControl.resolveLocalTarget. It never probes that path on the daemon.
A local conversation has a durable working directory on the computer, kept in brain_sessions.work_dir. The resolver's local branch answers placedWorkDir ?? baseWorkDir ?? target.cwd: a delegated child's placed directory, else the recorded directory every caller passes as baseWorkDir (spawner, owner turn context, channel turn, status), else the elowen connect service's own cwd, which applies only when nothing is recorded, such as a web-started conversation. localClientDir(cwd, target) gives a recorded directory its one shape in the computer's own path form (localTargetPath): the client's cwd, which must be fully qualified (absolute on Linux and macOS, with a drive or UNC share on Windows), resolved without a trailing separator and never checked against the daemon filesystem. The resolver applies it to baseWorkDir and to a placed directory, because only the turn's own scope may describe the computer to a runner; ConversationLifecycle applies it through its localTarget dependency, which is sandbox.resolveLocalTarget. ConversationLifecycle.stampWorkDir stores it on /brain/start and every /brain/send that carries a cwd from the conversation's own computer, and a new conversation started from a paired computer spawns in that cwd: the CLI names no target there, so ConversationLifecycle.start resolves the Project from the authenticating device token (computerRefForDeviceToken) and a device-token start never lands on a server Project. ConversationLifecycle.clientCwdFor is the one gate: a request whose personal API token is not that computer's device token contributes no cwd to a local conversation, so the conversation keeps working in its recorded directory (paired computers). resolveStartSession resumes a local conversation by that exact string, and only one on the same computer; a host start never resumes a local conversation. Its fallback to the newest conversation without a recorded directory keeps to the same machine too, because migration v74 emptied the directory of every local conversation and a resumed conversation ignores the requested target. Selecting a local target clears work_dir, because a host path names nothing on the computer, and the computer's default applies until a client started there reports its directory. The owner and channel turn resolvers (resolveWorkDir in src/brain/service/turnContextBuilder.ts and src/brain/channels.ts) read the ref and its base directory together on every call, so a Project switch between two tool calls of one turn resolves the new target against its own directory; once the target changed, the turn's client cwd no longer applies and the host base is the live cwd the switch committed. Migration v74 cleared the host directories earlier switches left on local conversations. A delegated child's directory is its scope's workDir alone, so the daemon's startup-instruction RPC confines a runner's reads to that same directory. /cd (noteWorkDir) still refuses a local conversation.
After the connection is bound, assertLocalWorkDir sends one stat (following symlinks) through SandboxControl.localFiles for the resolved directory, at spawn startup and at turn admission (TurnContextBuilder.build for owner turns, runChannelTurn for channel and delegated turns, except a hidden result delivery). A directory that is missing or is not a directory throws LocalWorkDirMissingError (src/brain/sessionErrors.ts): an Error with code: 'LOCAL_WORKDIR_MISSING', status: 409 and the path in its message. The turn never falls back to target.cwd. A delegated child's failure crosses the runner IPC as message text only; it reaches the delegating model, not an HTTP route. Status and fixed-session snapshots project project.connected from this same resolved local metadata alongside cwd and branch, without a second lookup or target listing. The shared session-identity contract owns that view for core, CLI and web. A disconnected selected target retains its local reference and cwd with connected:false; a missing Sandbox owner fails explicitly rather than resolving a host directory. Descendant snapshots use the child's target, contribution account and placement. Local delegated cwd resolves relative to its parent's cwd and may leave the startup directory. That directory selects initial instructions and is not a filesystem ceiling.
Core consumers resolve only Sandbox. Sandbox alone consumes the localExecution control, which core registers as a host control in the authoritative daemon over the paired computers' executor sockets (paired computers). A missing owner is a broken host and throws; a disconnected transport refuses execution. Neither transport falls back to host or managed execution; the explicit, recorded opening/admission switch above happens before transport binding. Host-only Git reports LOCAL_TARGET_UNSUPPORTED at its I/O boundary. Local file sharing, plugin file transfers and attachment uploads use the existing Sandbox file transport, described below. Conversation attachments and server-owned tools retain their recorded server storage. Plans, complete terminal captures, delivery/cold spills and content-addressed workflow results on a local target use that computer's home under .elowen/; failures never write a host copy.
PluginRegistry.control('sandbox') validates the complete registered Sandbox contract before composing core's runCommand. Host and managed test providers must therefore also supply SandboxLocalExecution methods even when the scenario never selects a local target. The one-shot command suite in tests/integrations/rv1009P22ProjectCommand.test.ts and Terminal spill suite in tests/plugins/managedTerminalSpill.test.ts provide typed, throwing local methods and assert that their provider resolves before exercising commands or persisted output. Missing methods keep the entire control unavailable; fixtures do not bypass this gate or fabricate a local result. The runner retains output bounds, cancellation and lease cleanup, while the spill tests retain real captured bytes, public Read, digest checks and explicit persistence failures.
guestAccessOf(resolved) in src/brain/managedArtifacts.ts projects an already-resolved managed turn into GuestAccess for core file consumers. It copies the live provider, Project reference and account id without resolving, caching or authorizing again. Call it only after resolveManagedArtifactTurn or resolveManagedArtifactsFor succeeds. It performs no I/O and changes no authority; the provider still authorizes each operation. Consumers retain operation-specific options: resolveSessionContextFiles adds startIfNeeded: false, so opening a conversation does not start an environment. With no managed resolution the caller retains its existing host branch or explicit refusal.
ownSessionGuestSpillDir(sessionId) in src/brain/managedArtifacts.ts is the shared derivation of a conversation's guest tool-output directory. It applies the existing immutable session spill namespace to guestSpillDirForNamespace; rebinding a conversation does not retarget existing references. The helper performs no filesystem access, creates nothing and grants no access. persistStreamedToolOutput uses it only on its managed branch; host output keeps sessionToolResultSpillDir. Read claims remain limited by the existing artifact and Project authority rules.
The private writeImmutableSpill primitive in src/brain/session/toolResultSpillStore.ts shares create-only collision adoption between storeSpill and persistToolOutputSpill. It writes through the caller's existing writer and reads the survivor only on EEXIST; byte-identical text is adopted and unreadable or different content is a conflict. Other write failures propagate to the caller: clearing logs and retains inline context, while complete host output preserves its throwing error contract. Directory creation for complete output remains outside collision adoption. There is no registration or extra storage path; a successful fresh write needs no verification read on the host.
Interactive computer shells
Sandbox delegates local openShell({project,cols,rows}) to core's authenticated
localExecution.openShell. The acting account comes from the API scope.
A daemon-owned terminal binding pins the computer's current socket, checks explicit
Project membership even for administrators, and permits the shared PTY vocabulary
only on that binding. An old client returns LOCAL_CLIENT_OUTDATED; offline and
unsupported computers retain their typed refusals. No host fallback or request replay
is possible. Terminal's signed-in web routes own session limits, detach grace,
takeover and X-to-kill; the local adapter owns the socket and shell lifetime.
See executor protocol and
client engines and packaging.
Local byte transfers
localFileCall(sandbox, input, signal?) in elowen-plugin-shared/localExecution binds the existing guest-file vocabulary to an admitted local turn. It forwards per-request cancellation, preserves transport errors and lets write-abort run after tool cancellation. resolveLocalPath(target, cwd, path?) resolves only with the computer's platform paths and an absolute cwd; drive-relative Windows input is refused. Work-directory admission, instruction files, Files, shares, downloads and uploads use this seam. It registers nothing and grants no authority; absent Sandbox is an explicit failure.
Core openShareSource handles ShareFile on host, managed and paired-computer targets. It authorizes the selected filesystem before returning {path, name, size, body}, where body is a lazy byte stream. Local paths and cwd are statted on the computer, and a source outside the resolved cwd still asks through resolveAsk. Managed paths resolve through openManagedGuestArtifact and the existing Project-authorized guest channel. Guest and CLI reads use readGuestStream, the same 128 KiB, version/size-pinned client that TransferFile uses; there is no extra transfer protocol. Host reads use a 128 KiB filesystem stream and check the inspected inode, size and change timestamps before and after copying. Missing providers and changed sources fail without host fallback. A managed in-flight operation retains its existing deadline because the Project channel has no per-request cancellation input; cancellation is checked between chunks, and local operations also receive the tool signal.
ShareFile enforces harness.sharedFileMaxBytes, default 5 GiB, before starting the stream. storeFileStream and storeContentAddressedStream copy into private mode-0600 staging, check free filesystem bytes before consuming the source, hash filename-prefix plus bytes incrementally with SHA-256, verify the exact declared size and sync before atomic publication in the existing chat-files store. Errors and cancellation remove staging. The regular reference sweep reclaims unreferenced outputs and dead-writer staging after its one-hour grace; live writer staging survives even a slow transfer. A copy is deliberate: a source hardlink could be modified after sharing and violate the content address. There is no global byte quota, and concurrent filesystem activity can still exhaust space after the preflight check. References persist according to existing conversation retention.
readShareSource collects that same stream only for bounded inline images and approved platform/plugin reads. A missing host path reports cannot find from the typed ENOENT code, while a directory reports is not a file; other read failures retain their diagnostics. ShareImage remains capped at 10 MiB because the display, model and provider paths decode its bytes. Plugin reads retain their caller-selected bound up to 250 MiB. ShareFile reads the current registered adapter's sharedFileMaxBytes and uses the smaller of that declaration and the harness limit before reading or copying bytes. Teams declares 20 MiB; Discord, Telegram, WhatsApp and Chatbot declare 25 MiB. An adapter without a declaration uses the harness limit. The platform name selects the registered adapter and carries no hardcoded byte policy. Ordinary Read never stages files in conversation storage.
openProjectUpload accepts a local destination from localUploadProject, resolved using the authenticated writer and the conversation's stored cwd. The Editor can instead name a computer Project and relative directory without a conversation: the same resolver checks the account's canExecute, uses the computer's connection cwd and retains membership and cwd checks through every part and commit. Explicit uploads use the chosen directory and existing collision suffixes. For a conversation upload, the destination is uploads/<account>/<date>/<name> inside that cwd. A local upload holds its own connection pin from begin until commit or abort, across HTTP requests and model turns. Every part and commit rechecks conversation ownership, target and stored cwd. Browser parts remain 16 MiB, regrouped into existing 512 KiB guest chunks. Channels use the same writer identity and upload path. A lost executor connection throws LOCAL_EXECUTION_LOST; a dispatched write retains mutationOutcome:'unknown'. The session is invalidated without a resume offset, and no request is replayed on a reconnected computer. Upload sessions remain daemon-memory state: restart loses them and later parts return 404. The client's socket cleanup discards unfinished staging.
Files' TransferFile uses host.projectFiles().transfer to stream one file between distinct named Projects, including computer Projects. Both endpoints use {kind:'project', projectId, path}; the selected-computer endpoint is removed. Core resolves computer paths on the named device and holds its binding throughout the transfer. Every local operation checks canExecute, including for administrators; there is no anonymous host endpoint. Daemon transfers hold independent pins for their computer endpoints; a runner keeps its inherited computer binding and cannot select another computer. Core admits the acting account and turn Project scope, rechecks live membership per operation and pins managed generations. Named host Project paths use the existing symlink-aware Project guard. Reads pin a content version; uploads hold one 512 KiB chunk and progress reports acknowledged bytes. Destinations are create-only unless expectedVersion names the replacement version. Move deletes the source with its observed version only after confirmed destination commit. Cancellation or failed/unknown commit leaves the source. A failed source deletion leaves the committed destination and reports failure; it is not a completed move. The default permission is ask, with normal account overrides and unattended rules. No persistent transfer queue or automatic replay is created.
Conversation plans
planLocation(sessionId, execution?, accountUserId?) in src/brain/managedArtifacts.ts owns plan placement. It uses the ambient execution target unless a lifecycle caller supplies one explicitly: host plans live under the instance data directory's plans/, managed plans under /data/.elowen/plans/, local plans under the paired computer's ~/.elowen/plans/. All use the same session slug. Any change involving a managed or local target queues a verified execution-target plan move. The synchronous local path resolver reads home/platform metadata through the current Sandbox owner; an absent resolver refuses instead of minting a server path. isPlanLocation admits exactly that session's absolute path, resolving host symlinks with containment; Sandbox checks guest containment inside the container. There is no host fallback and ExitPlanMode never writes a host mirror.
Core stamps currentAccess().planFile for the managed or local conversation's own Plan turn, never for a read-only child. Sandbox admits only writes/uploads to that exact file and create-only mkdir on its artifact-directory ancestors. Guest operations use the plans directory as their confinement root, so a planted file or directory symlink into the Project is refused. An internal exact flag also rejects resolution to a different file inside that directory; the upload transport carries the same constraint through every step. Core's non-destructive Bash rules decide command admission in Plan and read-only child turns. Environment lifecycle and Desktop mutations remain forbidden. Core's separate, plugin-inaccessible artifactFiles control can persist its own output during read-only turns.
On a paired computer the same core-only artifact control routes to localExecution.artifactFiles and carries the owning account and session. It retains an existing connection pin, or acquires a temporary binding for a durable maintenance operation; a retained lost pin fails with LOCAL_EXECUTION_LOST. Plan directives create the plan directory through this control before the model writes. Ordinary localFiles retains its read-only check and admits only write/upload operations for the exact own plan path. The daemon stamps those writes with scoped-file, a variant of the existing file operation with {root, exact:true}. Core artifact operations use the same variant with the paired home's .elowen root. The CLI enforces the resolved root in createLocalFiles, including parent symlinks and every upload step; exact also refuses a plan symlink to a different file inside that root. Older executors refuse the unknown operation rather than ignoring its scope, so this behavior requires a client update as well as a daemon update.
workflowArtifactPath(workflowId, nodeId, contentHash, execution, accountUserId, localMetadata?) is the one path derivation for publication, parent reads, recipient grants and deletion. Local paths are absolute and use forward slashes, including Windows drive and UNC roots. Core storage workers supply home/platform metadata from the account-owned device store without opening a local transport. ownSessionGuestSpillDir(sessionId, access) uses the same root and the session's immutable spill namespace. Producers bind the existing guestFiles client to the artifact channel; chunk bounds, create-only publication, complete-byte verification and version-pinned removal remain shared with managed storage. Failed delivery/cold publication retains the full inline result. Plan reads, artifact reads and writes preserve typed LOCAL_EXECUTION_LOST failures rather than reducing them to a text-only refusal. A streamed output publishes its final reference only after verified completion and removes its temporary parts through the same target. If both capture and temporary-part cleanup lose the connection, the aggregate failure retains the loss code, status and unknown mutation outcome.
setPlanPreparation binds the store's lifecycle drain in daemon and runners. readPlanForTurn awaits it before reading a plan; failed pending work fails the turn explicitly. Without a binding, isolated consumers perform their normal plan read without a lifecycle queue. applyPlanOperation executes ordered, durable move/delete intents through the same guest file client. Transfers create the destination, verify complete bytes and remove the version-pinned source. Different destination content refuses ordinary moves. Clear, delete, re-key and execution-target changes record intent in the session transaction. Maintenance runs at startup and hourly without starting stopped containers; a requested plan access can start its authorized environment to finish pending work. A kernel lock beside a file-backed database serializes drains across daemon and runners. Recorded core moves run with the real caller's account identity through runWithIdentity, independently of its newly selected execution target; Sandbox still checks current membership and plugin grants on both Projects. Maintenance retains failures, skips their dependent move chain and continues independent conversations before reporting the combined errors.
A managed delete-only intent that receives project_forbidden settles as done only when the core projects.get repository confirms that its source Project no longer exists. Managed Project removal follows verified runtime cleanup, so there is no remaining plan deletion to perform. This check runs after the provider refusal, outside SQL, and uses the existing short acknowledgement transaction; it also drains previously failed rows on the next startup/hourly sweep without a migration. A still-present Project (including deleting), revoked membership, a different caller identity, a failed lookup, or any transfer keeps the normal failure/retry behavior. Error text alone never establishes absence, and surviving host copies retain their independent cleanup intents. Regression coverage: tests/brain/managedPlanDeletion.test.ts uses PostgreSQL repositories and an in-memory provider.
Native computer wait for plan intents
drainPlanOperations(db, instanceRoot, resolver, sessionId?, prerequisite?)
accepts the canonical ComputerPrerequisiteWake<PlanOperationContinuationReference>
as its fifth argument. Immutable planOperationId is its native identity and
generation, independent of a live session, so cleanup survives session deletion.
The existing file observer, create-only publication, complete-byte comparison and
version-pinned source removal reconcile uncertain mutation outcomes without
replaying mutation RPCs or borrowing another computer/server.
Both original local endpoints are freshly authorized and checked before file I/O.
The first unavailable endpoint parks the same intent; repark may identify the
other ORIGINAL endpoint. The captured current-connection predicate is rechecked
after awaited authorization/readiness and before dispatch, independently of native
claim checks. A targeted claimed wake waits for the existing kernel lock instead
of losing its context in the background zero-timeout coalescer. Existing ordering,
conflict chains, request-file coalescing and background startIfNeeded=false remain.
Success and native prerequisite settlement use one tx.repositories transaction.
setPlanComputerWake(((projectId) => void) | undefined) binds the daemon's existing
coordinator submission. Only after native park/repark commit and a fresh authorized
online check does the plan consumer submit that wake. It never joins coordinator
execution while holding the kernel lock; submission errors remain logged by the
daemon. A required online submission without wiring fails explicitly. No new
scheduler, polling loop or retry timer is introduced. Focused regressions exercise
the real coordinator, PostgreSQL claim and held kernel lock for initial park/repark;
this does not assert complete daemon or packaged acceptance.
Rollout and rollback
Back up the database, host plans and managed persistent volumes together before rollout. On daemon core construction, queuePlanMigration records existing host copies of managed and local sessions once, with completion markers in the same transaction. Its additive local_completed marker also discovers local copies on instances that already completed managed migration. Runners attach the lifecycle drain without initializing the migration. Migration preserves an existing verified guest plan as authoritative; a host-only plan is copied create-only and read back byte-for-byte first. No host source is removed before the guest is verified. The transfer bound is 256 KiB and oversized, non-regular, unavailable or conflicting files fail loudly. Pending rows and last_error remain in brain_plan_operations; stopped environments stay stopped during maintenance. A later authorized plan access or maintenance pass resumes the same intent idempotently.
Before rolling back code, quiesce conversation activity and either restore that coherent backup or export each current guest plan through the authorized artifact channel to the older version's host plan path, verifying the copied bytes before starting the old version. A code-only rollback does not recreate the host copies removed by migration and does not process queued lifecycle intent. Preserve the queue and marker when rolling forward again; never clear them to hide a failed migration.
Guest file agent
Managed file I/O has two owners. elowen-plugin-shared/guestFiles owns every sequence above an authorized files(operation) binding: paging, versioned streaming, parent creation, chunked uploads and create-only byte comparison. Core, bundled plugins and registry plugins use that client. Sandbox owns one-operation transport through projectFiles and artifactFiles, guestFileTransport.mjs, guestFileAgent.mjs and guestFiles.py. Fix operation counts and file semantics in the client; fix framing, session lifetime and authorization in Sandbox. Neither layer grants authority.
For example, publishGuestFile(files, path, bytes) uses the caller's existing artifactFiles binding for a tool-output spill. The runtime validates the public operation with fileOperation, authorizes the actual account and checks environment generation, running state, lifecycle work and worktree admission before each operation. These checks await the canonical PluginDb.transaction with { isolation: 'repeatable read', readOnly: true }; lease registration and actual mutations use write transactions. Tree-changing operations additionally hold the existing repository mutation lease. A warm read does not mint or delete an execution lease or start a host process.
For rename and copy, the transport checks source CAS before classifying a missing or non-directory destination parent as parent_missing. It creates no destination ancestors. The shared client creates them only for that code and repeats the operation with the same source version; not_found is not a parent-creation signal because the source may have disappeared. Existing destinations remain no-clobber conflicts, and a successful rename fsyncs both parent directories.
copyGuestFile(files, source, destination, { expectedVersion }) serves Editor's managed copy through that same authorized binding. The guest preserves files, directory trees and symlinks using shutil without following copied links. It stages the complete payload in a private directory on the destination filesystem, fsyncs its regular files and directories, rechecks the selected source entry and atomically publishes with renameat2(RENAME_NOREPLACE). Failure cleans only that operation's staging directory; a destination created during staging is preserved. A tree cannot be staged inside itself. Copy transfers no file bytes over the host protocol and remains subject to the file-agent operation deadline.
remove is nonrecursive unless recursive: true is explicit. The recursive form uses the same mutation lease and CAS, removes only the selected tree, unlinks symlinks without visiting their targets and fsyncs the parent. Directory CAS for both copy and remove versions the selected directory entry, not a snapshot of every descendant. Nested content can change without changing that directory version; callers must not treat it as a whole-tree transaction.
The first operation lazily opens one agent per environment generation in the owning daemon or runner process. Its durable files-agent lease belongs to that first account. NspawnClient.prepareFileAgent(spec, executionId, { signal, onInterrupted }) publishes the content-addressed Python source and prepares the existing worker socket-stdio execution session with lifetime: 'connection'. Its one-shot start() exposes stdin, stdout, stderr, cancellation and completion; settle({ cancel }) owns termination proof. There is no additional socket, host rootfs access or alternate execution path.
Each request is a u32 big-endian byte length followed by JSON { id, ...operation }; each reply is framed JSON { id, ok, result | error }. IDs correlate replies that arrive out of order. Requests are bounded at 1 MiB, replies at 16 MiB, and the guest uses four worker threads with a stdout lock. EOF is a clean exit. A truncated, oversized or malformed request returns an error frame and exits with status 1; invalid reply framing or a mismatched ID or operation kind discards the host session.
Each operation has a 120-second deadline. Expiry closes the entire session and rejects its outstanding operations without replaying a mutation. Idle sessions close after 60 seconds. Each owning process holds at most four agents, evicting the oldest idle session when needed and refusing capacity when all four are active. Cleanup sessions use the same pool and bounds. The privileged worker separately caps file agents at 16, independently of its 32 ordinary executions. Session-close debug logs contain operation count, accumulated operation time and maximum operation latency; they are diagnostics, not a performance guarantee.
Stop, restart, snapshot and access revocation close the owning session through cancelLeases. Revoking any account closes every shared file-agent lease for the affected resource, even when another account opened it. Revocation waits for cancellation; other accounts' in-flight requests fail with the session-close error and their next authorized request opens a new connection lazily. Ordinary execution leases remain account-scoped. File-agent leases are excluded from unresolved-writer and idle-worktree admission accounting, like the existing viewer and preview leases. The worker handles owner loss, stale lease reconciliation handles dead owners, and a later request opens a new agent after worker loss. Detach drains and closes the process's agents. Upload quiescence uses withCleanupSession over persisted upload scopes after ordinary admission has closed, retaining the generation fence and closing the cleanup session on every exit.
The Python agent keeps at most 64 version entries keyed by (dev, ino, mode, size, mtime_ns, ctime_ns), with pinned inotify watches and epoch checks. These detect ordinary mutations but cannot detect every mmap write. Stat and mutation CAS checks therefore always hash fresh, without joining an earlier pending hash. The shared client marks a multi-page read with readPhase: start hashes fresh, continue may reuse the pinned version, and end hashes fresh after reading the final block, before returning it. EOF also forces that final check; a standalone read without a phase checks both boundaries. The client marks bounded ranges ending before EOF explicitly. An opened read continues the same snapshot; when it already covers the requested range, its fresh hash is authoritative as the final page and reuse adds no guest operation. A tiny managed Read therefore uses one read including classification, while a multi-page snapshot uses the final read rather than a separate closing stat. Thus an unchanged stream hashes about twice its file size rather than once per page. Every later page carries the original expectedVersion; metadata/watch/target changes and final content-hash mismatches fail with version_conflict. All hashing runs outside the shared cache lock; pins and LRU eviction bound watch lifetime, and overflow fails closed. Versions remain SHA-256 of tuple and content. Regression coverage includes mmap changes for reads and every mutation CAS, a 200-page two-hash stream, concurrent cache pressure and cleanup in guestFilesParity.test.ts; framing and capacity remain in guestFileAgent.test.ts.
The local CLI's createLocalFiles.operation returns a promise. Full-content versions
use asynchronous 512 KiB reads, so socket heartbeats and other file requests can run
between blocks. Stat and mutation CAS always hash fresh. Up to 64 observed stat/read
versions are retained per connection: a stream start with the same expected version
and unchanged stat tuple can reuse its stat hash. Descriptor and tuple checks surround
each transferred block and the resolved target is checked again. End, EOF and standalone
reads always hash the entire content afresh before returning the final block. The usual
stat/start/end share therefore hashes twice, not three times; metadata-only versions
would lose detection of writes that leave cached metadata unchanged. Mutations are
serialized across their asynchronous CAS checks. Connection close aborts pending hashing,
discards uploads and clears pins; completed streams also release their pin. The executor
awaits results before serializing the existing file-result frame. No wire protocol changes.
walkGuest(files, path, { limit, skip?, maxDepth? }) delegates Files discovery and OneDrive tree mirrors to one bounded guest walk operation. The guest never follows symlinks, reports truncation at its entry or depth bound, and propagates traversal failures. The local CLI adapter resolves an explicitly selected directory symlink before walking, even outside startup cwd, and reports the resolved directory as root with rootKind: 'directory'. Files Glob therefore matches that directory rather than the link's parent. Descendant symlinks remain metadata entries and are not traversed; regular file roots still match from their parent, with the same entry/depth bounds.
Project management
src/api/projectSummary.ts builds the display-only Project register summary from rows
already authorized and placed in account order by src/api/routes/projects.ts. Call
projectSummary(ctx, allowed, user, admin) only after that route's tenancy filter.
Plugins receive only this allowed batch; foreign indicator ids are discarded, with at
most three indicators per plugin/project and eight per project. Registry reads retain
their five-second budget and each provider its two-second budget. Member samples stay
admin-only and capped at sixteen. Managed branches retain four concurrent readers, a
four-second batch budget and the shared thirty-second memo; absent or failed providers
omit the branch instead of reading host storage. The web Project cards are the real
consumer. No projects means an empty summary, with no member or branch rows fabricated. Administrator member summaries use one userProjects.forProjects batch for authorized ids before the existing directory/administrator projection; computers still show explicit members only.
Minimal use: a route calls the Project service with the caller as an actor and a body thunk,
as src/api/routes/projects.ts does for create (service.create(actor(c), () => c.req.json())).
The service is the single implementation of every Project operation an account can perform,
so a new caller goes through it rather than repeating the tenancy checks. Its plugin dependency
uses the existing PluginContext.control and ContextRegistry.projectRemovedHandlers contracts,
not the concrete registry and its tool-context assembly, keeping the Project tool dependency acyclic.
plugins/sandbox/lib/environmentRuntime.mjs composes managed Project lifecycle work and owns admission, live authorization, the reconcile queue and detach ordering. environmentResources.mjs owns usage validation, persisted snapshots, generation/epoch-qualified refresh promises, history and disk-ceiling enforcement. The runtime authorizes the whole usage batch before passing it to readUsageBatch, and rechecks history membership immediately before readHistory; neither path provisions a machine. The existing sampler admits at most 1,000 environments per sequential probe and commits snapshot/history groups of at most 32, with a real setImmediate yield between writer groups. Resolved enforcement promises alone do not yield to timers. Generation/epoch/start-time fences and atomic snapshot/history acceptance remain unchanged; persistence awaits the PostgreSQL plugin transaction. Missing or failed measurements retain the existing unavailable/stale presentation and diagnostics. Detach waits for accepted refreshes before publication mutations.
environmentAlerts.mjs projects those stored disk readings and recovery exhaustion onto ctx.alerts. It keeps the existing 80% warning and 75% clear marks, raises start-refusal conditions at admission and clears them on resolution or deletion. Without a host alerts contribution it emits nothing; emission failures are logged without failing lifecycle work. environmentPublications.mjs owns socket naming, per-publication mutation promises, durable binding and retirement cleanup, and recovery-state reporting. The runtime calls publication reconciliation after Project lifecycle work and drains admitted bindings/releases on detach. A missing forwarder is restored from its durable binding; a retired identity remains fenced before and after an in-flight start. Sites and previews consume the same binding control, and publication probes stay batched by machine.
Resource-history maintenance counts failed prune attempts, and resource sampling counts measurement, persistence and disk enforcement separately per Project. Only the third consecutive failure reports, once until success; every attempt remains locally logged. Invalid batch shapes and metric values report immediately. Periodic disk/start-refusal alert reconciliation uses the same third-failure rule per alert key, reset by a successful raise or clear. One-shot start refusal, terminal recovery and deletion cleanup alert emissions remain immediate because they have no periodic retry owner.
Host runtime readiness and the Host runtime settings panel share one ordered items list. Root filesystem artifacts retain their verified download cache; no automatic artifact garbage collection runs. Failed downloads still remove their own incoming file, and every reused blob is verified before materialization.
The catalogue publishes only the current recipe revision. Existing materialized disks retain their source image and disk identity independently of the artifact cache: daemon boot reconciliation, explicit restart and host boot recovery prove the existing envelope and reuse the disk without looking up a retired pin. Network unit enablement is baked into the archive; no runtime normalization RPC changes existing roots. environmentRuntime.mjs::assertImageReplacement serves both request admission and recovered durable recreate/restore intents, refusing a retired environment or snapshot with retired_image before quiescing writers, stopping a machine or changing its specification. Restore copies only current-image v2 snapshots and retains their source image provenance without relabeling. retiredImages.test.ts covers the deployed @1, @2, @16 and @18 families, pending/running intent recovery, unchanged data and disk identity, and absence of downloads.
Runtime integration tests live in tests/plugins/environmentLifecycle/: lifecycle admission, start/stop, snapshots, recovery, deletion, execution leases, file binding, adoption rollback, desktop stop, machine-host readiness and concurrency have separate suites. resources.test.ts and diskCeiling.test.ts exercise the resource owner, publications.test.ts exercises durable publication ownership, and rootfsArtifacts.test.ts exercises artifact binding; alert coverage remains in tests/plugins/environmentAlerts.test.ts. fixtures.ts shares the existing runtime setup, cleanup queue and common inputs, with an independent in-memory database, scratch directory and mocked machine client per setup. Vitest isolates the fixture module per test file, and its afterEach drains only that file's cleanup queue. Guest-file and resource helpers used by one suite stay local to that suite.
Within web/modules/projects/, ProjectCard composes ProjectTeamStrip for ProjectsView and draws
its own ResourceMeters/ComputerLoadRings reading. The strip owns membership presentation, measured overflow, its non-passive
wheel listener and mouse-edge animation with blur, leave and unmount cleanup. It consumes only
horizontal paging keys and clicks on the strip or count, preserving the card's other navigation.
Absent membership renders nothing; a served empty team stays screen-reader-only. Cost is linear
in the bounded member sample, with one resize observer and animation frames only during drift.
The card no longer carries a location mark: ProjectDrawer's Overview names it in
ProjectLocationGroup, as an editable host folder for a manager or as the guest root and adopted
path of a managed Project, and reads only the supplied project. Both retain the existing tooltip,
avatar, motion-preference and horizontal-wheel UI seams.
Sandbox owns managed Project resource history in lib/resourceHistory.mjs, through its existing plugin database and ten-second due-work clock. One detached refreshResources path serves current cards, history and disk-ceiling enforcement: stable running environments are sampled every minute, stopped or failed materialized environments every five minutes. Active lifecycle operations and deleting or unmaterialized targets leave gaps. Nspawn reads timestamped cgroup counters before awaiting its coalesced five-minute disk-tree cache; disk history carries the oldest physical observation time. Disk transport failures and invalid helper reports reject the resource batch into refreshResources' existing sandbox.resource_usage_failed diagnostic, leaving the durable snapshot and history unchanged. Successfully returned unmeasurable trees produce unavailable readings only for their environment, with prior card readings retained as stale.
Sandbox's internal lib/nspawnMeasurement.mjs owns pure conversions of cgroup CPU, IO and memory counters, systemd timespans and the MiB disk ceiling. NspawnClient imports these directly for ownership-limit checks and resource batches; host reads, timestamps, caches and privileged operations remain in lib/nspawn.mjs. Invalid CPU/IO/memory counters retain their errors, an empty IO report means zero traffic, and malformed timespans return null. Parsing costs only the supplied text scan and performs no I/O. The runtime client's disk surface keeps ownership-checked remove(spec), removeStorage(spec) and removeSnapshotStorage(spec, savedManifest); incomplete snapshot receipts remain recovery evidence. The real nspawn proof uses stop(spec) followed by remove(spec) before checking liveness and removing storage.
The operation store in plugins/sandbox/lib/environmentDb.mjs projects updated_at_ms in its bounded recentOperations read with PostgreSQL (EXTRACT(EPOCH FROM updated_at)*1000)::bigint. Disk-ceiling cleanup grace reads that value for the first successful start after a ceiling stop; the persisted timestamptz projects epoch milliseconds for the same fifteen-minute window. This is a read projection over existing operation rows, not a second timestamp store or migration.
The current snapshot's generation, refresh epoch and refresh-start CAS must accept before history is appended. Fresh probe values enter history before the card retains prior unavailable readings. Snapshot, selected minute observations and hourly aggregates commit atomically in awaited PostgreSQL plugin transactions covering at most 32 environments, using the supplied transaction-bound store and history handles. Validation and probes finish before the transaction; filesystem walks and lifecycle enforcement stay outside it. Failed persistence is logged and does not bypass generation-fenced disk enforcement.
Each Project/series/minute contributes at most one sample and updates its UTC-hour aggregate once. Recreation within a duplicate minute or an hour marks discontinuity without fabricating zeroes. Minute detail lasts 48 hours; hourly mean/min/max and original measured percentages last 30 days. Live reads enforce those horizons independently of pruning. Each maintenance tick deletes at most 1,000 expired or project-removal rows in a separate short transaction, with a durable deletion queue and no backlog loop.
GET /plugins/sandbox/api/environments/usage-history uses the plugin-owned resourceHistoryTypes.d.ts contract. The authenticated Sandbox grant and request project scope precede a fresh runtime membership/managed-Project gate immediately before awaited indexed PostgreSQL reads. Reading never provisions or refreshes. Current members, including read-only accounts, share retained Project history; revoked, foreign, host or deleting targets are refused. The strict handler validates types, exposed parameter names, timezone-qualified instants, bounds, resolution and metric grammar from the host's existing query object. Live window=1h|6h|24h|7d|30d is mutually exclusive with fixed custom from/to; unknown windows and mixed forms are rejected. The server resolves exact elapsed durations against its own now and includes the current partial bucket; omitted windows and bounds select live 24h. Client clocks never anchor live history. The fake daemon shares this boundary and range calculation rather than approximating bucket rounding. Repeated query keys follow the host's existing projection. Valid ordered windows are clamped to server now and its 30-day horizon; the response preserves requested bounds and marks adjustments with adjusted. A window with no retained overlap returns 400 with range_outside_retention. Requests select at most 720 time buckets each; older ranges use complete stored hours plus the current partial hour and report effective bounds. The metrics parameter takes at most five names — cpu, memory, disk, io and network — which expand through resourceHistorySeriesIds into the seven wire series, because io and network each become two.
The Sandbox administrator account-environment route, environment GET, returns mode, probe, home, author and migrationCollision. Its browser declaration is EnvironmentState in the plugin's web-src/runtime.ts; the user drawer (web-src/EnvironmentSettings.tsx) reads mode for host-execution policy and probe for readiness. Network reachability is not a separate response field. This read never selects a managed Project runtime.
Sandbox's internal lib/workspaceTransfer.mjs owns the workspace inventory and the verified cross-filesystem copy used by ContainerStorage.adoptWorkspace and releaseWorkspace. Callers retain their preconditions and atomic same-filesystem rename; on EXDEV they supply the existing staging path, error messages and target-restoration policy to copyWorkspace. It checks destination free space, inventories the source, copies and verifies staging, then syncs staged files and directories. Before the final source inventory it writes a durable source-side transfer receipt and atomically renames the original directory to its sibling <source>.transferring. Only the detached tree is validated and removed; host Files Write operations on the original name cannot race its final inventory and deletion. Staging activation and target-parent fsync precede a durable committed receipt, recursive removal and source-parent fsync. Adoption restores an empty target after capacity/copy failure; release leaves it absent. Staging cleanup failures preserve both errors and take precedence over empty-target restoration. A pre-activation failure restores the source only if its original name remains absent; restoration failures retain both errors and the detached bytes. After activation or partial removal, recovery owns the remaining tree. Both callers inspect <source>.transfer.pending and <source>.transfer.committed before their normal path checks, validate the exact source, target and staging ownership, and either restore an unactivated source, re-prove an activated copy or resume committed removal. The committed receipt is removed last. A recreated source is preserved and fails the transfer and its retry instead of being overwritten or removed. Nonempty-target adoption uses the same detach-and-verify path; no successful host write is discarded during resumed cleanup. Inventory walks retain sorted paths, file sizes/content digests and symlink target bytes. Size and inventory traversal, timestamp-preserving copying, fsync, activation and recursive removal use awaited filesystem I/O, including resumed adoption cleanup. Each root entry must resolve before copying, preserving refusal of a dangling root symlink; links inside a copied directory retain their verbatim targets. The shared async syncPath also serves disk and snapshot durability: every caller awaits it before advancing its existing journal or activation sequence, and the handle closes before an fsync rejection propagates. Bounded ancestor path guards remain synchronous, as does the no-yield absent-name check and rename used to restore a detached source. These are private storage primitives, not execution authority: lifecycle leases exclude guest writers, while source detachment protects concurrent host Files writes admitted under all-access policy. Open host descriptors or processes writing directly to the retirement path are not fenced by a pathname rename. No transfer does any work unless invoked; an EXDEV transfer costs a full copy and three inventory walks, with no byte cap, unbounded parallel copy or privileged runtime call. Sequential I/O avoids long synchronous filesystem bursts on the daemon event loop, at the cost of additional filesystem dispatches; sorted inventories and their JSON comparisons still use memory proportional to the entry count.
The account HOME routes in lib/api.mjs await lib/execution.mjs::directorySize(path) before reading the Git author and active leases. This on-demand, asynchronous filesystem walk yields during directory reads and file stats instead of blocking the daemon's event loop, with at most 64 concurrent stats per walk and byte totals added in listing order. It preserves filesystem listing order and the directory stack, counts every listed entry up to 20,000, sums regular-file sizes, and does not follow listed symlinks. Missing or unreadable directories and raced file stats retain the existing best-effort result. truncated remains true at exactly 20,000 entries or with directories still queued; the walk is not an atomic snapshot, and a large directory listing is still materialized before applying the entry cap. The account HOME reset routes share the internal homeResetPreview(userId, state). It builds the payload and SHA-256 from already loaded state, serializing userId, generation, bytes, entries, activeProcesses and author in the existing order. Neither route performs extra state reads; reset still checks the hash before the typed phrase and rechecks generation/active leases under the existing HOME lease. resetUserHome keeps that generation/lease check, old-HOME rename, new private directory and generation publication synchronous before its first await, then awaits recursive removal of only the retired HOME. The reset API's existing withRepoLease awaits the complete operation; a removal failure propagates after the new generation is published. removeUserData inventories proven legacy Git worktrees before awaiting recursive account-directory removal and prunes only those proven repository records after removal succeeds. Removal failures propagate and prevent prune. Neither path adds a deletion queue; interruption can leave detached old HOME files on disk. runPrepared clears its heartbeat and releases its execution lease unconditionally in finally, including launch failure. lib/runtimeProcess.mjs exposes only bounded completion capture for SpawnExecutor: independent stdout/stderr tails, truncation, exit status, timeout and abort. Nspawn is its actual consumer; there is no streaming observer.
Sandbox's artifact builder owns immutable revision publication through pinsFrom: a generated candidate such as { reference, digest, sizeBytes, path } must agree with an already published pin. buildRecipe measures staged compressed bytes before replacing an output archive, and writePublication validates the entire manifest before writing either metadata file. Identical rebuilds, new revisions and unpublished first publication are accepted. Rejection leaves prior archive and metadata bytes intact; this local validation does not upload assets or make the two metadata writes a filesystem transaction. The constant-size pin comparison is reused at both write boundaries. The builder's internal test entry accepts a tiny recipe, command executor and pin table so regression tests never bootstrap a real image. CLI --verify passes verification mode into the same buildRecipe: it measures and inspects temporary staging, skips publication checks and archive copies, and returns metadata with output: null for verifyArtifacts to report matching or differing bytes. Verification leaves output directories, archives, manifests and pins untouched. Standalone recipes produce their own provenance; manifest entries omit the unused base and baseRecipeDigest fields without changing recipe revisions or archive pins. The streaming tar/PAX reader lives in plugins/sandbox/tools/lib/tarReader.mjs, consumed directly by the builder and its archive fixtures; it retains GNU long names, hardlinks, base-256 fields and bounded member capture. scripts/build-rootfs-artifact.mjs remains the stable CLI named by generated pins, with no test-export barrel. Real-disk proof callers pass source/target specifications to disk operations and an explicit Project tree owner to direct fingerprint requests, so a host-path refusal is exercised with valid authority.
Sandbox's PostgreSQL schema in plugins/sandbox/lib/schema.mjs indexes per-resource operation order, the maximum reserved restore generation,
and the subset of settled receipts without machine-ownership checkpoints. saveOperation trims at most
128 eligible rows older than the newest 20 operations through those indexes. Restores carrying oldSpec
or newSpec remain durable ownership receipts for ownedRecipes during Project deletion; they are
excluded by the partial index instead of re-parsed during every trim. recentOperations, used by disk
limits and resource polling, seeks the requested tail without sorting the resource's whole history.
The background operation dock reads durable runtime_operations rows in Sandbox's host-selected PostgreSQL plugin schema. The table includes initiator and acknowledged_at; the full human HTTP identity stamps ui, turn credentials stamp agent, and automatic work stays system. Core managed Project creation and execution selection pass this provenance through the existing lifecycle control. Project deletion instead follows the core durable operation described above; Sandbox acknowledges its storage participant. GET /plugins/sandbox/api/environments/operations returns { operations }, limited to the caller's own UI work and unseen settled outcomes from the last 24 hours, with fresh managed-Project access checks. A successful own deletion remains readable after Project removal. POST .../operations/ack { operationId } confirms only own settled rows, idempotently, and publishes an owner-targeted frame so a second tab drops it even after deletion. Running acknowledgement returns 409, foreign or inaccessible work 404. The shell's UI API 54 useOperationDock shares this list and pushed rows; no browser storage duplicates Sandbox operation state. Core project_deletions owns the separate cross-plugin deletion operation, not a second Sandbox runtime job. No Sandbox means no environment source. Opening progress uses the existing single-operation reader; see docs/WEB.md for the view, timing and accessibility contract.
Every Project operation an account can perform has one implementation, createProjectService in src/projects/projectService.ts. The HTTP routes (src/api/routes/projects.ts for create, edit, adopt, delete, order, members and memory members; the membership writes in src/api/routes/users.ts) and the agent's Project tool (src/brain/tools/projectTool.ts) both call it; the routes read it from ServerDeps.services.projectService, while the brain receives that exact object as BrainDeps.projectService. buildBrainCore constructs it once through createCoreServices, including the daemon's cliConnections; a forked runner constructs its own process instance without executor sockets. The service authorizes through the shared tenancy predicates in src/api/access.ts and validates bodies with the API's own zod schemas from a body thunk, so the routes keep reading their body only after the existence and authority checks. A refusal is a ProjectOperationError carrying the status and body the API always answered with; routes render it verbatim and the tool reads the same message. A caller is a ProjectActor: the account, plus for an agent turn the turn's live Policy.allowedProjectIds (a delegated child's narrowed scope), which narrows every Project-scoped operation on top of the account's authority. That scope is resolved before the turn runs and drops a Project once its deletion begins, so the actor also carries conversationProjects: the Projects the tool's conversation created or began deleting through the service, which the service records and adds to the scope for management only (its own checks, and projectIds during its provider calls). The conversation can thus start and manage the Project it just made and follow the deletion it started; work inside a Project still sees only the policy, and a delegated child gains nothing, since it may not create and what it deletes was already in its scope. A managed environment stays the Sandbox plugin's: the service reaches it only through the sandbox control (environmentFor, environmentSnapshots, requestEnvironment, environmentOperation, revokeProjectAccess, releaseAdoptedWorkspace), and the provider validates limits, network, ports and snapshots and decides who may change them. Sandbox's containerSpec.mjs::normalizeContainerLimits owns the arithmetic and closed resource shape for request admission, provisioning defaults, specification creation and live updates. Call it with partial limits such as { cpus: 2.5, swapMb: 0 }; absent ceilings use DEFAULT_CONTAINER_LIMITS, and supplied invalid values throw before enqueueing. Stored reads retain their existing missing-field projection, and live deserialization retains its nullish-default rule. The durable operation executor revalidates limits through that same helper before checking disk capacity or changing pre-start intent or live limits. Invalid recovered operations fail without changing the stored valid limits, so the environment remains readable. The constant-size validator requires whole CPU microseconds and bounded integer ceilings; environmentRuntime separately checks the measured disk volume. Positive numeric provisioning defaults are validated instead of silently replacing the whole set. Those calls run under runAsProjectManagement (src/plugins/policyContext.ts), which stamps currentAccess().projectManagement: inside a turn the provider then judges a Project the tool names by membership and the turn's project scope rather than refusing everything but the conversation's selected target, as the HTTP API does. Outside a turn the marker changes nothing.
Absent wiring: without the tool's dependencies the Project tool answers that Project control is unavailable (src/brain/tools/projectTool.ts:319). Without BrainDeps.projectService (src/brain/brainDeps.ts:115), list and switch still work and every management action answers that Project management is unavailable (src/brain/tools/projectTool.ts:365).
The Project tool keeps one flat, provider-compatible object schema with an action discriminator; the strict zod union inside execute is the validator. Actions: list, get (the Project plus, for a managed one, its environment and snapshots), switch, create, update, delete, order, members, add_member, remove_member, memory_members, adopt, start, stop, restart, recreate, resources (unnamed ceilings keep their current value, because a limits action replaces all seven), network (mode and the full inbound port list), snapshot, restore, delete_snapshot (the Environment tab's durable deleteSnapshot action), operation, logs (the lifecycle log and the guest journal, read as the newest lines, default 100 and at most 1000), worktrees (listing never starts the environment), worktree_create and worktree_remove. Project is the only agent path to a managed environment; Sandbox registers no environment tools of its own. Environment actions answer a pending operation. Plan mode and read-only delegated children receive it through READ_ONLY_AGENT_TOOLS for its reads (list, get, members, operation, logs, worktrees), and the tool refuses every other action in plan mode or when the turn carries a read-only origin. A delegated child may not switch, create or order. delete, restore, delete_snapshot, adopt with undo (it removes the environment's containers, storage and snapshots) and a smaller diskMb confirm through resolveAsk (src/brain/toolPermissions.ts), the same resolution an ask permission rule takes in the execute-time gate: a prompt where a human is attached, YOLO and the unattended setting elsewhere. They ask even after an ask rule approved the call, because the gate's prompt for a non-shell tool names only the tool. stop, restart, recreate and network need no confirmation: each replaces only the machine envelope and keeps the root filesystem and project storage. A resources change re-reads the ceilings after its confirmation, because the provider takes all seven at once and a ceiling change does not move the generation; a disk ceiling that changed while the person was answering refuses with limits_changed. Results are compact projections (no avatars, e-mail or host storage paths).
ProjectStore.removeRows is the final SQL cascade behind finishDeletion, after every captured participant acknowledges cleanup. It rebinds eligible ordinary human conversations and clears administrator/chatbot references without migrating chatbot accounts. A delegated child's historical delegated_access remains unchanged, but its work is terminalized and restart authority is consumed; that retained scope is not permission to execute on the host when the Project is deleting or gone. Admission and recovery reject that target rather than resuming delegated work through a host fallback.
ProjectService.authorizedRows(actor) returns stored project rows in the account's canonical order without filesystem projection. It delegates to projects.listForUser(userId), one authorized ordered row query: administrators see host and managed rows plus their assigned computers; other accounts see their current assignments. With either account store absent, open mode returns all rows; with both stores present, an absent account returns no rows. Use service.authorizedRows({ user }) before projectSummary(ctx, allowed, user, admin). The summary route and ProjectService.list/get share this selection; list/get attach their existing filesystem projection afterward. Account-row selection does not replace the session policy or the existing turn-scope checks for project-scoped reads and mutations.
projectSummary returns the canonical ProjectSummary wire contract. Its indicator map uses ProjectSummary['indicators']; the exhaustive ProjectIndicatorTone dictionary validates incoming tones using Object.hasOwn. The registry is resolved once per summary under a five-second withTimeout. A load failure or a timeout rejects the whole summary as PluginRegistryUnavailableError, which src/api/server.ts answers with HTTP 503 plugin registry unavailable; no partial register is returned. Managed branch batching receives the Sandbox control from that resolved registry rather than loading it again, and without a registry or a Sandbox control managed cards carry no branch. Each indicator provider retains its two-second budget and output bounds of three indicators per plugin/project and eight per project. Managed branch reads retain four concurrent readers, a four-second batch budget and the thirty-second memo; member samples remain administrator-only and bounded to sixteen. Empty authorized rows produce an empty summary. Project register cards are the real consumer.
Project HTTP actors derive lifecycle-operation provenance through operationInitiatorFromCredentialScope from the host-verified credentialScope. Full and impersonation credentials map to ui, agent credentials to agent, and api/advisor or absent credentials to system. Caller bodies cannot select provenance. This pure classifier adds no I/O and does not change direct-tool defaults; project creation and the existing environment operations consume the actor.
The same Sandbox ProjectEnvironmentControl publication methods accept
ManagedProjectRef | LocalProjectRef: projectPublicationAuthorize({project,accountUserId}),
projectPublicationBinding({project,accountUserId?,publicationId,port}) and
projectPublicationRelease({project,publicationId,retire?}). Sites keeps consuming
the unchanged {generation,socketPath} result. For a local Project, Sandbox stores
the port and publishing account in its existing publication record, then delegates
the live listener to core's local execution host control. An account is required
to create or change the intent; an account-free recovery may only restore the
recorded port, using the saved publisher's current canExecute. A non-member
administrator cannot publish. A disconnected computer keeps its binding but
closes accepted connections, allowing the existing Sites unavailable response.
Sandbox derives each local listener path with the canonical managed publication
naming helper under the Project broker directory and supplies that trusted
socketPath to core's binding and release methods. Core owns transport permissions
and does not derive Sandbox paths. Each visitor gets a raw TCP tunnel to the computer's
literal loopback port, carrying both HTTP and WebSocket upgrades. No environment
or execution lease is created. Sandbox reconciles local records before managed
inventory so a managed-runtime failure cannot prevent computer recovery.
Retirement is written before cleanup and fences a late binding. Member removal
stops that publisher's listeners and tunnels; a reconnect never adopts old
connections. prepareRemoveMember returns a commit callback accepting both
Repositories and SqlExecutor. For local Projects, preparation invokes Sandbox's
revokeProjectAccess before awaiting process cancellation, and the commit callback
registers the same invocation with SQL afterCommit. Sandbox closes the publisher's
listeners under its per-publication mutation locks, retaining durable intent. The
post-commit close catches reconciliation that restored a listener while membership
was still valid. Direct removal and account preset changes pass their transaction's
SQL executor to the same callback. Core's revokeMember only owns process and turn
binding cancellation. See executor protocol.
Both lifecycle restoration and periodic publication reconciliation use the same per-identity locked recovery block in environmentPublications.mjs. The block re-reads the durable Project binding after entering the lock, skips bindings that were removed and forwarders already proved active, and starts missing forwarders using the current binding's port. Binding reuse and lifecycle restoration check existing sockets through NspawnClient.publicationStatuses([{ spec, publicationIds }]), after a fresh inspect proves running state, live limits, machine registration and ownership. Periodic reconciliation retains one batched status read without per-machine inspections. The existing publication-status-batch worker operation validates each group's namespace, generation, disk, spec hash, pinned identity and publication unit names, then runs systemctl -M directly. A status check creates no transient guest probe unit; individual inspections still run their host commands. Batches accept at most 128 groups, 128 units per group and 512 units in total. Missing results, group errors and transport failures reject the check and never trigger a forwarder restart as though it were inactive. An inactive forwarder uses the existing restoration path, including stop-before-unlink cleanup if retirement wins during start. Recovery failures retain caller-specific logs and do not stop the Project. No durable binding means no forwarder to restore. Sites and previews consume this through the existing projectPublicationBinding control, whose { generation, socketPath } response is unchanged.
configuredLimitMeasurements(limits) also owns configured resource denominators: memoryBytes and diskBytes convert MiB using 1024 squared, ioBytesPerSec converts decimal MB/s, and networkBytesPerSec converts decimal Mbit/s, with zero bandwidth represented as null. NspawnClient.resourceUsageBatch and environmentResources' unavailable-resource projection consume the same constant-cost conversion; state, observation time and probe availability stay with the callers. With no measurement, configured denominators remain available and usage remains null. This helper accepts normalized limits and performs no host reads.
RuntimeClient in lib/runtimeClient.mjs documents the complete injected NspawnClient contract. Resource consumers call diskCapacityBytes(spec) for the host-derived filesystem capacity and resourceUsageBatch(entries) for one to 1000 ordered observations. An unreadable capacity is null at the client boundary; writing a disk ceiling refuses it, while a read may display an unknown bound. Failed resource transport rejects into the existing refresh diagnostic. Host readiness, provisioning, networkReadiness and waitForNetwork are required methods; incomplete injected clients are not an alternate runtime mode. ensureLiveLimits and ensureNetworkLimit keep independent applied/refused caches with five-minute refusal retention. Shared-link checks observe the leader before checking the leader/rate key; isolated environments make no network-limit call. Lifecycle start, live updates and daemon reconciliation are the existing consumers.
elowen-plugin-shared/resourceHistory owns the current resource-history vocabulary: cpu, memory, disk, io and network expand through resourceHistorySeriesIds(metrics) into the seven wire series. RESOURCE_SERIES states each series' physical source, value field, limit field and unit; CPU receives its limit from the caller. The same module owns the retained windows, the range/resolution rules and the bucket shaping, so a scope outside this plugin cannot drift from them. The bundle reads labels, colours, compact rate figures and the chart builder from elowen-plugin-ui-kit/resourceHistory, a generated browser artifact that inlines this vocabulary because a bundle cannot import a server module (the generator and its drift check are described under "Shared precise ranges and time charts" in docs/modules/web/components.md); metric icons stay local to each surface. The server sampler and history response, ResourceHistoryMetric/ResourceHistorySeriesId types, browser response validation and chart selection derive from this vocabulary. Shipped migration CHECK literals remain frozen in lib/resourceHistory.mjs. With no requested selection, the HTTP history boundary selects all five resources; missing observations keep the existing explicit gaps. The vocabulary performs only bounded selection work, makes no requests and adds no metric registration mechanism. ResourceHistoryModal, supplied through the host UI runtime, is the browser consumer.
ProjectExecutionInvariantError represents refusal of an unavailable or unusable execution target. Callers distinguish it with instanceof; its name and contextual message remain available, and it has no separate code field. preparePersonalProject throws it when a restricted account has no usable default Project, without creating a conversation or selecting the host. The class does not recover, provision an environment, or alter the selected target.
The managed and local branch readers consume the existing runProjectCommand lifecycle through managedGitReader and localGitReader. A SandboxCommandError whose reason is timeout records an unavailable branch locally and retains the short failed-read memo. Caller cancellation remains local-only; unexpected environment and command failures retain project.branch_read_failed. No branch read starts an environment, changes authorization or adds a second execution lifecycle.
Managed Git branch labels
RealGitReader.branch(path) reads only the checked-out branch label, in one command: git rev-parse --abbrev-ref HEAD. A detached HEAD (Git answers the literal HEAD), a Git-attested non-repository and an unborn HEAD all return null, because no branch is checked out to name; src/brain/service/gitBranch.ts gives a host worktree the same answers, and every surface then draws no branch line. Strict managed execution rethrows launcher and provider failures instead of treating them as repository verdicts. readManagedBranch is a real consumer shared by GET /projects/summary and GET /brain/status. It queries only running environments, never starts one, and uses the existing execution identity, cancellation and command deadlines. With no provider or running environment, no branch is displayed. The branch-label cache, concise projectStatus and full porcelain snapshot remain separate contracts.
A conversation on a paired computer gets its branch from readLocalBranch(connections, { target, accountUserId, sessionId, cwd }) in the same module, with cwd the directory effectiveTurnWorkDir resolves on that computer. localGitReader mirrors managedGitReader: the same strict RealGitReader, with git -c core.fsmonitor=false run on the computer through runManagedProjectCommand, which accepts either a managed or a local ref and any prepareExecution provider. The provider is CliConnections.prepareDaemonRead(input, { accountUserId, sessionId }), the core-only twin of the localExecution control's prepareExecution that takes the conversation explicitly (no turn scope names it) and dispatches without counting the process as a device command. The read takes the conversation's binding with bindLocalTurn for its duration and releases it after. It never asks a computer without a live connection, answers within MANAGED_BRANCH_TIMEOUT_MS whatever the computer does, and joins a read still finishing behind an earlier answer instead of starting another. peekLocalBranch(read, maxAgeMs) is its memo, keyed by account, computer and directory; BrainStatusService reports it only while the computer is connected. GET /brain/status is the one consumer.
peekManagedBranch(projectId, maxAgeMs) reads the shared per-project memo; peekLocalBranch does the same for account, computer and directory. Named branches use the caller's window: thirty seconds for GET /projects/summary, five seconds for BrainStatusService. A settled no-branch answer, including a non-repository or detached HEAD, does not expire on an interval. Missing Git, reported as ENOENT or command exit 127, also waits for a real change; it remains an execution failure rather than a repository verdict. Both branch readers log this instance condition with reporting:'local-only', without incrementing the software-failure counter. No error-message matching is used. Other failed reads retain the five-second retry window. Host gitBranch follows the same no-branch rule for its direct HEAD read, with authorization checked before cache lookup.
SessionProcessService invalidates observations when a command finishes or leaves the process registry, using both daemon and runner process snapshots. invalidateProjectBranch(target) clears that managed or computer target's memo; invalidateHostBranches() clears all host observations because host commands carry no Project target. After a durable conversation reassignment, BrainService.reassignSession moves both completion baselines through SessionProcessService.reassignSession before the process registry publishes its ownership changes. The snapshots adopt the destination session ID and leave no baseline under the retired ID, so subsequent command completion still invalidates the branch memo. Computer admission also invalidates that computer on reconnect. A different local or host working directory has its own observation. Branch probes register no process handles, so they cannot trigger their own invalidation. A read already running when invalidated cannot restore the old memo. With no such change, an absent branch stays absent without further filesystem walks or Git launches. Installing Git or creating a repository externally becomes visible after a command completion, reconnect, or observation of a new directory.
The existing CLI stream still wakes every five seconds to project authorized status and emits one status-changed event when the label changes. Known branches continue to refresh on that cadence; a no-branch memo answers without probing. Cache lookups launch no commands or environments; host and remote cache bounds remain 64 and 128 entries.
Provider refusal during Project mutations
PATCH /projects/:id forwards the HTTP request's AbortSignal to ProjectService.update(actor, projectId, body, signal?). The service checks cancellation after reading the body, interrupts its wait for managed icon validation using Node's addAbortListener, and checks the signal again immediately before persisting the patch. Cancellation propagates its reason and no metadata is written, even if the guest stat settles later. The listener is disposed on success, cancellation and failure. The authorized projectFiles channel has no per-request cancellation input: its read-only stat may finish in the shared guest agent under the existing 120-second operation deadline; cancellation does not close that shared session or interrupt other callers. Direct Project tool calls without a signal keep the same validation flow.
Managed icon validation binds the actor's authorized projectFiles channel and calls statGuestFile(files, guestPath, { follow: true }) once. The transport resolves symlinks and returns the canonical entry.path; ProjectService requires a regular file and checks that path against the existing Project-root or system-path limits before persisting the supplied icon. The request is stat with followSymlinks: true and launches no guest command. Missing files return entry: null and produce status 400. Invalid targets and the transport's not_directory refusal, such as /opt/logo.png/a.png when logo.png is a regular file, also produce status 400 invalid icon path. Other provider or transport failures produce status 503. Refused validation applies none of the metadata patch.
ProjectService uses its existing private sandbox() reader for managed creation, managed icon validation, managed adopt undo and membership revocation. Call await service.update({ user }, projectId, async () => ({ icon: 'assets/logo.png' })) to validate and persist a managed icon, or await service.removeMember({ user }, projectId, userId) to revoke membership after guest access cleanup. Both the HTTP routes and the Project tool use these methods. Authorization and path validation or last-active-Project checks precede provider access. An absent plugin registry, a rejected registry load, or a registry without the Sandbox control produces ProjectOperationError with status 503 and the operation's existing refusal message. No icon or membership change is committed. A rejected registry load is not cached, so the next call retries; these methods add no retry, filesystem projection or environment start. The remove() operation is the exception: it awaits the registry itself, because host deletion also invokes projectRemovedHandlers. A rejected load there reaches the HTTP boundary as the plain 503 plugin registry unavailable, not as a ProjectOperationError, while a managed delete without the Sandbox control gets the ProjectOperationError 503.
Creating a managed Project creates its row and then enqueues an ordinary start operation for its environment, with request id project-create:<id> and the actor's initiator (agent when the actor has none). This enqueue is best effort: a failure is logged as project.environment_start_failed and reported with its redacted sample, and the Project is kept. A queued start is returned to the caller as environmentOperationId.
Network measurement and enforced ceilings
Every environment is also held to a disk speed ceiling, one figure for reading and one for writing, so a single environment that copies or builds heavily cannot make the host's other services wait for the disk. The built-in ceiling is 150 MB per second (decimal, the way disk makers and systemd count), about what a 7200 rpm hard disk sustains, and it applies to every environment that has no ceiling of its own, including those created before it existed. The plugin default (defaultIoMbPerSec) is what a newly created environment starts with, and administrators change it per Project on the Environments page, or with the Project tool's resources action (ioMbPerSec). The slowest ceiling accepted is 5 MB per second (IO_MB_PER_SEC_MIN in the sandbox plugin's containerSpec.mjs): the slider moves in steps of 5, and the plugin default, the Project tool and the privileged helper refuse anything lower. It is a property of the running machine unit, not of its identity: it is applied before each start, on a live change and, once per daemon boot, to machines that were already running, so it never causes a rebuild. Environments are also kept out of the host's swap. The swap limit is the amount of swap an environment may use beyond its memory limit, zero by default, so an environment that reaches its memory limit has processes stopped inside it instead of pushing its pages onto the host's disk, where every other service would feel it. The plugin default (defaultSwapMb) is what a newly created environment starts with, and administrators raise it per Project on the Environments page (the row above the disk speed), or with the Project tool's resources action (swapMb, in MiB). It is applied together with the disk speed, in the same way and at the same moments. On a Project's card the five rings show CPU, RAM, disk space, disk speed and network. Network shows the busier of guest download and upload in decimal Mb/s, with both directions in its tooltip. An unlimited network has no percentage arc; an isolated running environment reports zero external traffic. The history picker includes Network, plotted in Mb/s on the right axis. The daemon reads kernel host0 counters through the machine leader's network namespace, resets rate baselines on leader or generation changes, decreasing counters and gaps over two minutes, and retains both directions as netRx/netTx in the existing minute/hour history store. Migration 17 expands the stored metric enum without discarding existing history. The sampler uses one read-only machine-manager call per running shared environment, no guest commands or privileged operations. The disk speed ring is the busier of read and write over the last sampling interval, as a share of the ceiling, so a full ring means the environment is being held back by it; its figure is the busier direction in MB/s (two decimals under 10, one under 100, whole numbers above), and hovering it names both directions. The host resolves the limit to the block device that holds the sandbox data directory, so the data directory must live on a device the kernel can account (an ordinary disk or partition).
The Network speed resource row and the Project tool's resources action (netMbitPerSec) set an independent ceiling for download and upload. Zero is unlimited for both new and existing environments; the Project tool accepts whole numbers up to 100,000 Mb/s, while the slider offers 0–10,000. A positive rate changes live without a restart and stays outside the machine identity. Before boot, the helper saves a per-machine systemd start hook outside the creation identity. Every real unit start, including an in-guest reboot, must apply the current ceiling to its newly registered link before systemd reports the unit active. A hook failure fails the unit start. The daemon also waits for registration and active state, and Start retries or resumes verify both disk/swap and network ceilings before success. The running-machine reconcile cache remains keyed by leader and rate. Refusals are held for five minutes and remain visible in the environment log. Isolated networking has no external link to limit, but retains the setting for a future shared network.
On a host whose kernel restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1, the Ubuntu 24.04 default), provisioning also writes the AppArmor profile /etc/apparmor.d/bwrap when the file differs and loads what it wrote with apparmor_parser --replace. Bubblewrap inside a machine needs a user namespace with its capabilities, which that kernel grants only to profiled binaries, and GNOME's glycin image loaders in the desktop image run in bwrap. Readiness reports apparmor:bwrap-userns from the running kernel, as it does IPv4 forwarding: the row is met when a bwrap (unconfined) profile is loaded, so a host that loaded an equivalent profile by hand is not refused, and its detail says when the managed file is missing. The profile is named bwrap because two profiles attaching /usr/bin/bwrap conflict and the kernel then refuses the exec. A host without the restriction has no such row and gets no file.
Host provisioning also installs iproute2, which provides the fixed ip/tc commands used for network limits; readiness reports it beside systemd-container. The root helper operation set-network-limit derives the systemd veth from its full alternate name, verifies both peer interface indices through the registered leader's network namespace, and accepts no caller-provided interface. Download uses a token-bucket queue on the host veth. Upload is redirected from its ingress to one root-created IFB with another token-bucket queue. That IFB's name is a digest of the machine name and its alias identifies its owner; an existing foreign link is refused. Returning to unlimited, stopping or destroying the machine removes its IFB. Stop still enters that ownership-verified cleanup after an in-guest poweroff has already stopped the machine. The mandatory unit hook calls apply-network-limit, which reads the latest configured ceiling under the environment lock and shares all interface/ownership checks with set-network-limit; it never writes an old start-time rate over a live edit. The hook imports the same root-owned helper by its own module URL. Existing helper byte comparison and worker digest checks therefore cover it, and privileged refresh updates it without adding an independently installed script. The queues permit a 64 KiB minimum burst or 20ms at the selected rate and a 100ms queue; rates are averages, not a promise against every individual packet burst. A disposable namespace proof with a 10 Mb/s cap and 8 MiB TCP transfers measured 9.64 Mb/s in both directions, with no drops. The simpler ingress policer measured only 2.54 Mb/s upload, so it was rejected. Removing the queues restored unrestricted transfers. Deployment needs the daemon build, privileged helper refresh, web build and then restart, in that order.
Immutable artifacts and execution identity
publishGuestFile(files, path, bytes) in elowen-plugin-shared/guestFiles creates immutable central artifacts with expectedVersion: null. Paths stay within the caller's artifact namespace, and parent creation and bounded payloads belong to that client. An existing destination is adopted only when its bytes match; conflicts and provider failures propagate. applyPlanOperation uses the same create-only guest writer for a missing plan destination; uploads and project images retain their separate live-version/CAS contracts.
Launch and settlement share machine/execution-unit identity validators. Settlement uses shownProperties and requires LoadState, ActiveState and ControlGroup. Worker owner-loss cleanup and Sandbox cancellation consume it; missing state fails. The untrusted request boundary still rejects raw execution fields.
The rootfs builder normalizes only the declared packages list. buildRecipe consumes the complete catalog list and publishManifest records installed inventory; no buildPackages path remains. DESKTOP_FORBIDDEN_PACKAGES remains an archive gate, with recipeDigest and published pins unchanged. Real build/verify remains required before publication.
Sandbox lease and runtime ownership
Sandbox desktopActionText uses the existing API55 runtime utils.interpolate for literal replacement. Direct composition tests install the real host runtime before calling it. No local interpolation fallback or additional host API is introduced.
Sandbox migration v18 removes heartbeat_at and expires_at from execution and repository leases, updated_at from runtime rows, and percent_min/first_observed_at from resource rollups. It preserves process identity, resource and execution IDs, generations, created_at, cancellation tombstones, raw samples, useful aggregates and resource sampling fences. Historical migrations remain immutable. Execution and repository lease heartbeats repair missing rows or verify ownership; elapsed time does not prove process death. Normal completion releases explicitly. Managed heartbeats still reauthorize the account and reject cancellation or a missing lease. Terminal execution is a real consumer; file-upload expiry remains a separate, active boundary.
Project deletion and adopted-workspace release use the same collection of currently supported nspawn recipes and the same owned-machine stop/remove sequence. Historical non-nspawn snapshots and operation checkpoints are receipts, never recipes to reconstruct as machines. With no historical current-runtime recipes, cleanup acts only on the current specification. Ownership inspection remains mandatory, and adopted release removes machines before returning the workspace through ContainerStorage.releaseWorkspace, then removes storage. Existing terminal resource-record cleanup is unchanged. The Project release operation is the consumer.
Nspawn hostReadiness() and provisionHost() always request veth readiness. The privileged gateway still owns its real isolated/shared network distinction. Machine envelopes carry binds, network, ports, capabilities, limits and UID identity, with no envelope environment option; execution environment values are carried only by each execution request. Managed shell commands use the byte-counted managedShellFrame so duplex protocol frames remain unread until exec. Production system-running polling is absent; the real-machine boot proof polls through the existing bounded exec transport. Sandbox's Project execution is the consumer. Client and root-owned helper bytes must be installed together.
selectRuntimeClient(spec, client) accepts the single nspawn driver directly. The persisted disk.runtime discriminator remains authoritative: unsupported historical runtime data is refused, and an unavailable nspawn driver is an error. No legacy machine, readiness or retirement sweep is removed by this simplification. ContainerStorage uses this boundary for every owned disk primitive.
Run rootfs build and verification from plugins/sandbox/tools/build-rootfs-artifact.mjs. Archive inspection belongs to plugins/sandbox/tools/lib/packInspection.mjs, which owns archive path validation, unit and payload checks, the base device allowlist and installed package inventory. The builder imports archivePath, assertPack and installedPackages directly; inspection tests import the same module. The generated artifact metadata deliberately retains the scripts/build-rootfs-artifact.mjs generator identity. This module split does not rebuild, repin or publish any artifact.
Project Git status exposes branch, head, upstream, ahead, behind, dirty and clean. The internal untracked count is used only to calculate clean, so a worktree containing only untracked files has dirty: 0 and clean: false. No separate untracked count is returned. Project detail consumes this read-only status; non-repository and managed-environment absence behavior is unchanged. GET /projects/:id/git answers 409 project environment is not running for a stopped managed environment and 503 when the provider is missing or cannot read the environment. It never starts one.