Repository navigation
foldyard 0.4.0
Every VM needs one fy machine stop && fy up after upgrading: the VM's boot script changed, and
until it restarts foldyard refuses a running Lima VM (the default backend) as stale. Three changes can need a config edit,
each spelled out under Changed: [plugins.github] becomes [[inject]] rows (its App key
moves to FY_INJECT_GITHUB in host.env), [proxy] passthrough now defaults to
["@jvm", "@linux"] instead of ["@all"], and [box].env refuses the names foldyard sets in the
box.
Security
- Your computer runs no git at all to heal the shared index. 0.3.2 hardened the supervisor's
git calls against a box-written.git/config; now there are none. The box's git shim builds the
healed index and leaves it next to the shared one, and the supervisor only installs it — under
git's own lock, and only if neither the index nor HEAD changed since, so nothing staged on your
computer is ever lost. Nothing to migrate: a box on the new shim is picked up byfy box up;
until then a stale host-sidegit statusis fixed bygit reset, as before the heal existed.
(Follow-up to GHSA-j5mq-v7p4-qw2j; ADR-0021.) - A program in the box can no longer hide what it fetches behind the image-build marker. With
the VM firewall on, image builds reach the proxy as userfy-build, and the proxy tunnels them
instead of decrypting them. Anyone could use that user, so the box could too: putting
fy-build:<anything>@in its own proxy URL turned a logged request (path, tool) into a
hostname-only row. The allowlist still applied. Now only a build carrying the secret foldyard
creates for it on your computer is tunnelled; a build started inside the box (fy upthere)
is decrypted and logged like any box request, and works because build steps now trust the
proxy's CA.
Removed
[plugins.github]is gone: GitHub is[[inject]]rows now, andfy uprefuses the old
table until it's replaced. The refusal prints the rows to paste, computed from your table: a
kind = "github-app"row (yourapp_id,installation_id,repoasrepositories, and
box_env = { GH_TOKEN = "x" }) and, commented out, akind = "gh-cli"emergency row for the old
userlevel. Three things move with it. The App's private key is read from the switch's
FY_INJECT_<SWITCH>inhost.env(FY_INJECT_GITHUBforswitch = "github") — rename
GH_PEM_B64, same value, and any[[secret]]row naming it.fy mode github=appis
fy mode github=on, and the oldgithub=useris the gh-cli row's own switch. And
permissionshas no successor: the token now carries the App installation's own
permissions — foldyard no longer narrows it topull_requests/issuesor refuses a map that
widens it, which is what turned a consumer's grantedactions: readinto a morning of 401s
(ADR-0031).
To change what the box can do on GitHub, change the App; for a second scope, a second App with
its own row. foldyard no longer installsghin the box: put it in your box image.
Changed
uv tool install foldyardis now the complete install on your computer — no[host]extra
needed. mitmproxy (the egress proxy) and PyJWT (the GitHub App minter) are ordinary
dependencies now, so the install you get by default is the one that works, and an upgrade
can no longer quietly leave the proxy out. The box, which only routes through the proxy, is
installed byfy box upwith both removed, as before."foldyard[host]"still installs (the
extra is kept, empty), so existing instructions and installs keep working.- Two switches can no longer both inject on one host and path.
fy mode(and the TUI's mode
buttons) refuse a level whose injection rules would overlap another switch's active ones (the
same host, with path prefixes where one is under the other or either covers the whole host). The
message names the other switch and the exactfy modecommand to run first. Before, a consumer's
[[inject]]row onapi.anthropic.combeside keylessclaude=onboth applied, and the proxy
sent whichever credential's rule came first in plugin load order. One switch may still split a
host by path, as Codex on a ChatGPT subscription does. The check runs on the rules the proxy
would actually get, so a rule that only appears with several switches on together is caught too. If your saved mode already has such a
pair on (or you adopt an[[inject]]row that creates one), the proxy injects neither until
one is off, and says so: a supervisor log line and notification, an error row infy modeand
fy state, acredential overlaprow infy doctor, and both rows markedHELD BACKin
fy config widenings, with the commands that end it. - A declared credential routes the box through the proxy, even while its switch is off. An
[[inject]]row, or keyless Claude or Codex, without a[proxy]table used to leave a box
created with that switch off with no proxy route and no placeholder, so switching it on later
couldn't reach the box untilfy box down && fy box up. The box now gets the route and its
box_envplaceholders whenever a credential is declared, and the proxy listener runs for it. - The proxy now decrypts the toolchain too:
[proxy] passthroughdefaults to
["@jvm", "@linux"]instead of["@all"]. With the VM firewall on, the box, your stack's
containers, image build steps and the VM's image pulls all trust the proxy's CA, and decrypting
a toolchain install measured within a few per cent of tunnelling it, sonpm,pip/uv,
cargo,go,git, image pulls, the Claude Code installer and gcloud are now logged request by
request. Only Java's own trust store (@jvm) andaptover HTTPS (@linux) stay tunnelled. A
project that setspassthroughitself is unaffected. To get the old behaviour back, set
passthrough = ["@all"](or add just the bundle a tool needs, e.g."@vcs"), then adopt it at
the nextfy up. The sign that a tool can't work decrypted is a certificate error naming the
proxy's CA (mitmproxy, e.g. Java's "PKIX path building failed" or Chromium's
ERR_CERT_AUTHORITY_INVALID). The box also setsCLOUDSDK_CORE_CUSTOM_CA_CERTS_FILE, so
gsutilandbqtrust the proxy likegclouddoes. [box].envis your project's environment, and refuses the names foldyard sets in the box.
fy box upnow stops before starting anything when[box].envsets a name foldyard owns:FY_*,
FOLDYARD_*, the proxy and CA variables (HTTPS_PROXY,NO_PROXY,SSL_CERT_FILE, …), the
engine socket (CONTAINER_HOST,DOCKER_HOST,DOCKER_CONFIG), the agents' homes and the
shell basics such asPATH. Before, such a name silently replaced the proxy, CA or engine
wiring, or was silently ignored, and the adoption diff showed it as one harmless-looking line.
The message names the setting to use instead where there is one ([box] clean_docker_config,
[box] git_index_split,[proxy] no_proxy,fy allow add).TZandLC_TIMEstay yours to
set. A key that isn't a plain environment-variable name is refused too: a quoted
"HTTPS_PROXY=x"would otherwise have setHTTPS_PROXY. An[[inject]]row'sbox_env
already refused the same names.
Added
fy doctorin the box checks that box git is foldyard's git shim. Every box-side git
protection, from the separate index to the checks that stop the box moving a branch your
computer just moved, lives in the shim at/usr/local/bin/git. A box image that puts another
git first onPATH, or a shim whose hooks didn't install, used to switch all of that off with
no sign. Two new rows say so.git shimfails whengitisn't the shim, andgit shim hooks
fails when its hooks are missing. It warns when a user other than root could rewrite them. Each
row names its fix. No rows when[box] git_index_split = false. A new box now runsfy doctor
once when it's created, as the last lines offy box up, so it reports this without being
asked. (ADR-0021.) Itsegress proxy CArow now says the box must be recreated too: the CA
is mounted when a box is created, sofy host restartalone never reached an existing box.- foldyard shows what a GitHub App installation grants, and tells you when that changes.
foldyard no longer caps agithub-apptoken's scope (the installation's permissions are the
box's scope), so it reports them instead. While the switch is on, its probe reads the
installation's permissions and which repositories it covers, as the App, minting nothing.
fy config wideningsshows the last reading under the row and how old it is, flagging any
writeoradminpermission, and any level foldyard doesn't recognise as if it were one
("not yet probed" until the switch has been on), andfy modeandfy tuishow it beside the
switch. All of them read a record on your computer and never call GitHub. When a reading
differs from the last one, for example after an org owner accepted a new permission on
github.com, the supervisor logs one line naming what was added, removed or changed level and
sends one desktop notification; the first reading is a baseline. When a probe can't read the
scope, the last reading stays, marked as possibly stale in those reports, and the supervisor
logs that once. An uninstalled or suspended installation now shows the switch as DEGRADED,
naminginstallation_id. - With an
[[inject]]switch off, a request carrying itsbox_envdummy gets an answer naming
the fix instead of the provider's "Bad credentials". The proxy answers it itself with a 401
whose JSONmessagesays the switch is off and to runfy mode <switch>=onon your computer,
whichghand most clients print. It matches where the row's rule would inject (host,
path_prefix, header orquery_param), and in a header the dummy may stand alone or follow one
auth scheme (ghsendstoken x, othersBearer x). Anything else goes on as before: a
public call with no credential, or a credential the box got some other way. The Network Log
marks these rows as held and the first one raises a notification, as for Claude and Codex. - An
[[inject]]row names its token protocol withkind, and can bake dummies into the box
withbox_env.static(the default — every existing row) is unchanged;github-appmints a
GitHub App installation token fromapp_id/installation_id(and optionalrepositories);
gh-cliinjects your ownghtoken and must beemergency = true. The GitHub kinds are pinned
toapi.github.com. Each kind takes a fixed set of fields, so a typo or a field the kind doesn't
take now stops foldyard loading the config, naming what is allowed — check your rows iffy
refuses one.box_env = { NAME = "dummy" }is what a client in the box needs before it sends the
header the proxy overwrites; it's baked whenever the box routes through the proxy, so turning the
switch on needs no box recreate, andfy verifyfails a box where it holds anything else. A
github-approw brings the old plugin's checks with it: the key's presence and shape in
fy doctor, the "can we still act as the App?" probe onfy mode, and the in-box check that the
token really reaches requests. - The dev box shows times in your computer's timezone and time format. Claude Code in the box
showed UTC timestamps, even though the box's clock was right.fy box upnow passes your
timezone asTZ(read from/etc/localtime) and your time locale asLC_TIME, which decides
a 12- or 24-hour clock.LC_TIMEis passed only when the box image has that locale and no
LC_ALLwould override it. ATZor
LC_TIMEin[box].envstill wins, soTZ = "UTC"keeps the old behaviour. Stack containers
stay on UTC. An existing box picks this up when it's recreated (fy box down && fy box up).
Fixed
- A box's first git command no longer sees every file as a staged deletion. It copies your
computer's git index to start from, and a git command on your computer that had just replaced
that file made it look missing from the box for up to a second (up to five on a Podman
machine). The box then started from an empty index. It now retries the copy briefly, and if
the index still isn't there it starts from the last commit. A commit on your computer hides the
branch in the same window, so that no longer passes for a new repository with nothing to copy,
and two first git commands racing in the box no longer overwrite each other's staging. - On macOS, starting a Lima VM no longer treats the running VM as an orphan. Before a start
foldyard reaps a Lima hostagent that outlived its VM, and it judged "outlived" by QEMU's pid
file. The default macOS VM type (vz) runs the VM inside the hostagent and writes no such
file, so a live VM could be waited on and signalled. That reap was also, by accident, what
recovered a hung VM. Now a VM that Lima reportsBroken, or that a graceful stop leaves
running, is ended with Lima's ownlimactl stop --forcebefore it starts again. The graceful
stop gets a minute first, where Lima would wait over six. - A mode change now updates a running stack on a Lima VM. The supervisor's posture reconcile
(andfy state's stack row, a new worktree's init,fy open) found the engine only through a
socket your shell named; Lima registers none, so a running stack read as down and a mode change
never re-rendered it. They now use the VM's own socket when it exists, without starting it.
CI had hidden this by exporting the socket into the commands it ran. - A VM restarted after a crash no longer hangs waiting for ssh.
fy uprestarts a VM that
says it is running but serves nothing, and that restart kept the ssh port it last recorded.
When the port was stale and taken, the start waited forever. The port is now re-pinned while
the VM is stopped. Where Podman Desktop is followed,fy doctorwarns when the VM's ssh
port is off its port range. - A checkout whose
foldyard.tomlyou haven't adopted says so. Your computer reads no config
for it until you adopt, and commands used to report that empty config as if it were yours:
"[project].composeis unset", or a project named after the directory. Now every command
says the config isn't adopted, andfy shellenvand every command that acts on the stack or
the box (fy build,fy ps,fy down,fy reclaim,fy verify, …) stop instead of acting on
a guess — also with an inheritedDOCKER_HOST.fy upstill asks to adopt. - A box git command that works no longer prints "No such file or directory". The box's git
wrapper leaves small notes for your computer next to the git index, and a note it can't write
is meant to be skipped silently. On a Podman machine a note your computer had just cleared up
can't be written again for a few seconds, and the error from that attempt still reached the
terminal, so a cleangit statuslooked like a failure. - Git in the box no longer loses, rewinds or reverts commits made on your computer. Over the
VM mount the box can briefly read a branch your computer just moved as older, or as having no
commits — for up to ~5 s on a Podman machine, up to ~1 s on Lima. In that window a box commit
could land on an old commit or start a new history, a boxgit resetcould move your branch
back over your latest commits, and a commit on your computer right after the box's could record
the box's new files as deleted. The box's git wrapper now checks every branch move while git
holds the branch's lock, and updates your computer's staging area as part of the box's own
commit. When the two sides collide it refuses with "foldyard git shim: … Nothing was changed;
run it again" instead. - Git hooks run in the box as they do on your computer. A repo hook's own
git(lefthook
runs a couple of dozen per commit) now goes through the box's git wrapper. Before, in the
moments after your computer committed, it could see every file in the repo as staged, and
formatters ran on all of them. lefthook no longer tries to install its hooks into foldyard's
own directory either, which printed "could not replace the hook: permission denied" on some
box commits. - A box commit no longer moves your branch under a rebase or merge your computer has
started, including one that began after the box first checked, which made yourgit pull --rebasefail to finish. The box refuses until your operation is done. fy upon a stopped stack after a config change no longer loses containers. The bundled
podman-compose removes every dependent of a service it recreates, but only re-creates the
dependents that were running — so a stopped one vanished, and anything that depends on it
failed with "is not a valid container, cannot be used as a dependency". foldyard now removes
those containers itself beforeup(and before a mode reconcile), so compose creates them
like any missing container. Named volumes are kept.- An install from a foldyard checkout reports the version it is actually running. An
editable install (uv tool install --editable) recorded its version once, when it was
installed, while the code kept following the checkout — so after pulling a new release,
fy --versionandfy doctorstill named the old number, the version window judged the old
number, andfy box upwarned that the box had a newer foldyard than your computer when both
ran the same code. foldyard now reads the version from that checkout'spyproject.toml, so a
pull is enough; no reinstall is needed just to correct the number. - A
git pull --rebasethat stopped before it began now says so, and how to recover. When
git can't start the rebase after it has set your uncommitted changes aside (its "autostash"),
it leaves.git/rebase-mergeholding just those changes. It isn't a rebase in progress, so
git rebase --abortcan't clear it, every latergit pullrefuses to start, and the box
refused every commit with "run it again once it's done", which never came. The box now names
it and points you atfy doctor, which shows the commands to run on your computer (one row per
checkout, worktrees included): they keep your changes ingit stash listfirst, then remove the
directory, then restore the changes by their stash id. foldyard only prints them; it never runs
git in your checkout. The box's new message arrives once the box is recreated
(fy box down && fy box up). - The bundled foldyard skill tells box agents that the stash list is shared with you. A bare
git stash poptakes whatever was stashed last, which could be yours, so agents now label
their stashes and pop them by entry. - When foldyard can't mint a credential, the box is told why instead of getting the
upstream's 401. A failed token mint used to forward the request with the box's placeholder
credential, so the agent saw the provider's own "Bad credentials" and guessed at causes — while
the real reason (e.g. a GitHub App permission the minter refused) sat in the host log. The
egress proxy now answers such a request itself with a502whose JSONmessagenames the host
and the minter's reason and points atfy host logs, without sending the request; a 401 whose
re-mint fails is replaced by the same kind of 502, worded for a request that did go out. The
Network Log marks the rowmint failed. The reason is the minter's diagnosis from its stderr,
with token-shaped runs redacted; its command and its stdout (where a token comes back) never
reach the box. - With the VM firewall on, your stack's containers can reach HTTPS hosts outside
passthrough.
The firewalled VM sends every container's traffic through the proxy, which decrypts every host
not onpassthrough, but only the dev box trusted the proxy's CA — so a service calling, say, a
third-party API failed with a certificate error. The VM's boot setup now installs the CA and a
podman default that mounts it into every container and setsNODE_EXTRA_CA_CERTS,
SSL_CERT_FILE,REQUESTS_CA_BUNDLEandGIT_SSL_CAINFO(a value your image already sets
wins). It changes the VM's boot setup, so an existing firewalled VM asks for
fy machine stop && fy uponce. - Turning the VM firewall off no longer leaves podman pointed at the proxy until the next
restart. The first boot without it kept the old proxy settings, so image pulls failed with
"connection refused" until the VM was restarted again. - A slow or failing credential mint no longer freezes the box's other traffic. The egress
proxy ran each token mint on the one event loop every connection shares, so a token service
that hung held up all of the box's proxied requests (its agent's own API calls, package
installs, git) for up to 30 seconds, and again on every retry of the failing request, since
clients retry a 502. A mint now runs off that loop: only the requests that need that
credential wait for it, and the ones arriving meanwhile share the one mint. A failed mint is
remembered for 15 seconds, so retries get the samemint failed502 at once instead of
re-running the token service, and the host log gets one line per attempt rather than one per
retried request. A secret pasted intohost.envis picked up straight away, without waiting
for the 15 seconds to pass. - A request two switches both claim is answered with the overlap, not the provider's 401.
When two switches that inject on the same host and path are on together, the proxy injects
neither, which is the safe choice, but the box's request still went out with its placeholder
credential and came back as the provider's "Bad credentials", which looks like a broken
token. The proxy now answers such a request itself, without sending it, with a502whose
JSONmessageis the same textfy modeshows: both switches, and thefy modecommand to
run on your computer to turn one off. It matches exactly where the held-back rules would have
injected (host and path prefix, decrypted HTTPS only), and the Network Log marks the row
credential overlap. - A service that crashed while a credential was failing now comes back when it recovers.
[resnapshot_on_capability]only recreated services that were still running, so one that
fetches its secrets at startup and exited because that fetch failed stayed down after the fix
(for examplejust gcp-elevate), even though the notification said the switch had recovered.
It took a manualfy up. A listed service whose container exited with an error is now
recreated as well. One that was stopped (exit 0, or by Ctrl-C orstop) or never started is
still left alone. List such services under the switch, as before. - Streamed replies reach the box as they are sent, not all at once at the end. The egress
proxy held back every decrypted response of unknown length until 1 MiB had piled up or the
upstream finished, so a Claude or Codex reply streamed overtext/event-streamarrived in one
piece when it was complete: no first token until then, and no keep-alive event either, so a long
reply could trip the client's idle timeout. It now relays every successful response as it
arrives, injected or not; on loopback the first of four events 0.8 s apart went from 2.4 s to
0.01 s, the same as a direct connection. Error responses are still read whole, so a 401 is still
re-issued with a fresh credential and the Network Log keeps its error snippet.passthrough
hosts were never affected. - With the VM firewall on, image build steps trust the proxy's CA. A
RUNstep got the CA
files but not the variables that point clients at them, so a build started inside the box
(fy upthere) failedapk,curl,pip,uvornpmagainst any host outside
passthrough. The VM's boot setup now adds those variables to a build step, as it already did
for running containers: anENVin your Dockerfile still wins, and nothing is written into the
image. GnuTLS clients (Debian'saptover HTTPS,wget) and Java still need the host on
passthroughor the CA in the image. - The CA bundle containers get includes the usual public roots again on Fedora 44 VMs. It
held only the proxy's CA, so a client readingSSL_CERT_FILEorREQUESTS_CA_BUNDLE(uv,
pip,requests) failed everypassthroughhost. Both land in the same one-time
fy machine stop && fy upas the other CA changes. - With the VM firewall on, image pulls work from a registry the proxy decrypts. A pull is the
VM's own podman, not a container, so the CA that containers now get didn't reach it: a registry
offpassthroughfailed with x509 "unknown authority". The VM's boot setup now also adds the
proxy's CA to the VM's own trust store, replaces it when the CA changes, and takes it out when
the firewall is turned off. It shares the one-timefy machine stop && fy upthat giving
containers the CA already asks for. - Turning the VM firewall off now really takes podman off the proxy. The firewall's proxy
settings for the VM's user were removed only for users with an id of 1000 or more, and the VM's
user takes your computer's (501 on macOS), so the first boot without the firewall still sent
image pulls to a proxy that was no longer running.