ADR-0026 — The VS Code attached-container config is declarative [vscode] data, read from the adopted copy¶
- Status: Accepted (2026-09-14). Built:
[vscode] extensions/[vscode.settings]read viadevmode.worktree_config()behindconfigpin.gate("fy code");remoteUserand the daemon-port pin as foldyard facts; the machine-settings marker reset; one attach shape (the checkout folder). Deleted: the in-box generator path and its sanitiser, any read of.vscode/extensions.json, and[vscode] workspace_file. - Sources: the one consumer generator script that existed, and the question "can the box make host VS Code install an extension?", asked while reading it. Related: 0022 (the channel this joins), 0023 §2 (the triage answer this amends for the VS Code surface), 0024 (the same finding, for plugins: data wearing a class).
Context¶
fy code attaches an isolated VS Code instance (its own --user-data-dir, one per worktree) to the
running dev box. "Attach to Running Container" ignores devcontainer.json; the lever for installing
extensions and applying settings on attach is an attached-container config that VS Code reads from
the instance's own globalStorage. Under ADR-0023
a consumer script produced that document in the box and foldyard sanitised it against a key
allowlist before the one host-side write.
Everything such a script can compute decomposes into three kinds of thing:
| what it produced | whose fact it is |
|---|---|
remoteUser |
foldyard's — fy shell/fy claude exec as root; a consumer choosing otherwise would be a bug |
extensions |
a list |
settings |
a table |
Nothing there needs to be computed. This is ADR-0024's finding again: the thing that looked like it needed code was data.
The finding that set the boundary. The extensions list is consumed by the Dev Containers
extension on the host, with no click. Workspace-kind extensions install in the box — inside the
boundary. A UI-kind extension (extensionKind: ["ui"], any marketplace author's choice)
installs on the host and runs as the operator — and the isolated --user-data-dir deliberately
shares ~/.vscode/extensions with the operator's default VS Code, so it is installed for their
everyday editor too. Sourced from a script or from .vscode/extensions.json files in the mount,
that list let anything that can write the checkout have host VS Code install and run a marketplace
extension of its choosing: host code execution from the repo mount, laundered through the
marketplace — the class ADR-0023 exists to close, reached by a door it didn't look at.
Decision¶
fy code authors the attached-container config itself, from the ADOPTED [vscode] table. No
consumer code runs to produce it, nothing under the mount is read for it, and the attach is always
the checkout folder.
[vscode] extensions = [...]— the auto-install list, as marketplace ids, id-validated, de-duplicated, with the Dev Containers extension dropped. Config, not.vscode/extensions.json: the list decides what the host installs, so it lives where the adopt gate reviews it. The recommendations files keep their plain-VS-Code role (click-to-install), which is the human gate this list bypasses. The duplication is the cost, and it is small.[vscode.settings]— a table carried into the config'ssettings(both schemas, so newer and older Dev Containers versions alike apply it). Settings can't execute; the schema's lifecycle hooks are not a key. The daemon-port pin merges on top and wins.- Read from the adopted copy, behind the gate.
fy codecallsconfigpin.gate("fy code")then reads the table throughdevmode.worktree_config()— the funnel every host-consequence verb uses (ADR-0022). A box edit to[vscode]is inert until an operator adopts it, and the adoption diff is where "this branch wantsx.yinstalled on your machine" is seen. remoteUseris pinned torootby foldyard, not declared.- foldyard keeps the last config it wrote as the record of what the box's Machine settings hold,
and clears
.writeMachineSettingsMarkerwhenever the settings differ (a first write included) — the twin of the.installExtensionsMarkerreset. Dev Containers applies an attached config only once per server install; without this, a changed setting never reached a box that had been attached to before. - The
_generatedByownership contract is all-or-nothing: remove the key andfy codeleaves the file alone, so the table stops applying to that instance. It is a host-side file under~/.foldyard, so the box can't reach it. - One attach shape.
fy codeopens the checkout folder (--folder-uri), never a workspace file. A worktree is attached the same way for its whole life, so window-scoped state VS Code saves in one session is read by the next.
The allowlist goes with the generator: it existed to bound a document foldyard didn't author. With
foldyard naming every key itself, a consumer-supplied initializeCommand has no path into the file.
Where settings land, and what that is for¶
VS Code resolves settings in an attached window in this order, later wins:
User (the isolated instance's own settings.json, under ~/.foldyard)
< Remote [Machine] (the box's vscode-server — where [vscode.settings] is applied)
< Workspace (the checkout's .vscode/settings.json, in the mount)
Each layer has one job:
- Remote [Machine] is the team's policy.
[vscode.settings]is the only thing foldyard puts there: editor defaults, per-language formatters, tool paths, agent flags. It sits on the persisted vscode-server volume, so it survives box recreation, and it is the slot VS Code designed for "settings for this environment" — machine-scoped keys (interpreter and binary paths) are honoured here and ignored at the local user level, which is why this and not the instance's user settings is the delivery target. - Workspace is the person's. The checkout's
.vscode/settings.jsonoverrides the policy, and foldyard never writes it, so a consumer can keep it gitignored and let each operator hold their own tweaks there — window colours per worktree, a personal ruler — without a diff. Being per-checkout, it is per-worktree by construction. - User is the operator's, plus foldyard's instance plumbing (the local-terminal shims, the
port and git-bridge pins) and nothing a consumer declares. A new instance is seeded once from
the operator's own VS Code User folder, minus the settings that name a container engine; that
is where
update.modecomes from now (amended 2026-09-24 — foldyard pinned it tomanual).
Per-operator overrides of the policy go in foldyard.local.toml: its [vscode.settings]
deep-merges key-by-key over the shared table and is adopted alongside it, so one setting can be
changed without redeclaring the team's. Overrides of the experience go in the workspace layer.
Considered and not done¶
Delivering [vscode.settings] into the instance's User settings instead. foldyard already owns
that file, and the write would be immediate rather than marker-gated. Rejected because User is the
lowest layer and the wrong scope for a remote window: machine-scoped keys are silently dropped
there, so the first path-shaped setting a consumer added would not apply. Remote [Machine] is the
designed slot; the marker reset makes it reliable.
A workspace_file key (open a multi-root .code-workspace instead of the folder). It carried
one consumer's per-folder-formatter workaround and gave one worktree two attach shapes over its
life: a first attach on the folder (the generated file did not exist yet), later ones on the file.
VS Code reads window-scoped settings from the folder's .vscode/settings.json in a single-root
window but only from the workspace file in a multi-root one, so what an operator set in the first
session silently stopped applying in the second. Per-folder formatter defaults are a consumer's
editor policy, expressible by committed per-folder .vscode/settings.json files, and not
foldyard's concern.
Shadowing .vscode/ with overlay volumes so the box can't edit it. It would defend the residual
channel — VS Code itself still reads the mount's .vscode/ when attached — but the boundary this
codebase draws is host consequences come from adopted config, not mount files are immutable,
and the second is a losing game: .vscode/, .editorconfig, a pyproject naming a tool path —
every file an editor reads is a candidate. It also breaks the yard's own model (agents legitimately
edit workspace settings; a shadow diverges from the checkout under branch switches, on both sides
of the mount). The residual exposure is VS Code's workspace-trust model for any trusted workspace,
and in an attached window nearly all of it executes in the box: the extension host, tasks, launch
configs and terminals all run remote. What executed on the host without a click was the
extensions list, and that is what moved. If a specific .vscode key is ever shown to reach the
host, the fix is the same shape as this one — name it, pin it, or move it to config — not a
mount-wide overlay.
Consequences¶
- A consumer generator script has no job left. Editor policy it merged into the checkout moves to
[vscode.settings]; extensions it collected move to[vscode] extensions; a multi-root workspace it generated is replaced by committed per-folder settings. [vscode.settings]reaches thefy codewindow only. A native VS Code window on the same checkout sees neither the policy nor, if the consumer gitignores it, a workspace file. A team that needs policy in native windows commits.vscode/settings.jsoninstead and gives up the personal layer there — that trade-off is the consumer's, and this ADR only makes it explicit.- An agent adding an extension on a branch now needs an operator to adopt before it installs. That friction is the point: it is the exact moment the operator should see the id.
fy config wideningslists[vscode]as the editor attach — not for the extensions (a follow-up: they are a repo-declared ask of the host in the same sense as an injection target) but for what the attach itself does. Found while this ADR was in review (2026-09-17): Dev Containers forwards the host's SSH agent and git-credential store into every terminal it opens in the box, and no setting stops it (launching VS Code withSSH_AUTH_SOCKstripped still forwards — macOS shell-env resolution). A live agent with one key sat in a box whose posture read "never push"; the defeat one consumer's image carried (an rc-file unset) was never foldyard's, and the socket is usable by path regardless of the env. What the process holds it forwards verbatim, though — only an unset var makes it hunt for the host's agent — sofy codenow launches VS Code with foldyard's own empty ssh-agent: the SSH side carries no credential by construction, since no setting chooses what is forwarded. The git side is weaker:git.terminalAuthenticationis pinned off at user and machine scope, but workspace settings win and.vscode/settings.jsonis mount data, so that pin is a default; the enforcement is the bootstrap's in-box hygiene (box._HARDEN_SNIPPET: the vars unset in every shell, the IPC socket reaped — re-ensured byfy codebefore the attach, which refuses if it can't), which also covers a manual attach, plusfy verifyfailing a checkout that re-arms the setting. The egress wall fences CONNECT to:443. The gate is still what stops the box; this is what stops the attach.- A future surface that genuinely needs computed output from the yard should reach for ADR-0023 §2's split again — this ADR removes one instance of it, not the pattern.