diff --git a/specs/clone.md b/specs/clone.md index 1100afb..e750f3f 100644 --- a/specs/clone.md +++ b/specs/clone.md @@ -169,7 +169,7 @@ On `clone` create, the CLI MUST: - **Reuse:** MUST NOT delete that workspace volume, including failure cleanup after create, start, ownership, populate, or hooks. MUST NOT run in-container git clone populate over an existing tree. If the mounted volume has no object entries other than `lost+found`, populate is allowed. If it has entries, skip populate and keep the tree; still apply author identity when `.git` exists; still overlay an edited `devcontainer.json` on `--resume`/recovery when that path already exists. - **Replace:** delete-then-create. Failure cleanup MAY delete only the volume this invocation created after the operator chose replace. - Config `type=volume` mounts remain list-then-create/reuse per Named volume reuse policy — no prompt. `rebuild` MUST remain unable to delete `*-ws`. -2. **`rebuild` carve-out:** `rebuild` of a volume-mode managed container MUST **reuse** the existing `*-ws` volume tree with its data and MUST NOT delete, replace, or re-populate it; MUST NOT run git re-clone or `git pull` inside it. The freshness rule applies to `clone` only. +2. **`rebuild` exception:** `rebuild` of a volume-mode managed container MUST **reuse** the existing `*-ws` volume tree with its data and MUST NOT delete, replace, or re-populate it; MUST NOT run git re-clone or `git pull` inside it. The freshness rule applies to `clone` only. 3. Mount that volume as the **container workspace folder** (the implicit workspace mount). MUST NOT bind-mount a durable host project directory as the workspace for clone-created containers. 4. **Existing occupant of the create name:** classify per **Create-name occupancy classification**. Same-workspace same-name MUST fail closed (MUST NOT silently reuse, replace, or attach; MUST NOT offer rename-to-duplicate). Foreign occupant MUST follow **Foreign create-name collision offer**. Same-workspace different-name MUST fail with a delete-hint. 5. Set labels on create: @@ -360,7 +360,7 @@ before returning the structured failure. A reused pre-existing workspace volume **Lifecycle (clone fresh create)** -At the start of `clone`, when a host checkout exists, the CLI MUST run host `initializeCommand` per [lifecycle-hooks.md](lifecycle-hooks.md) **initializeCommand host execution**. After successful populate, `clone` MUST run create-path lifecycle hooks with the **same matrix as `up` fresh create**: +At the start of `clone`, when a host checkout exists, the CLI MUST run host `initializeCommand` per [lifecycle-hooks.md](lifecycle-hooks.md) **initializeCommand host execution**. After successful populate, `clone` MUST run create-path lifecycle hooks in the **same order as `up` fresh create**: `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` @@ -396,7 +396,7 @@ At the start of `clone`, when a host checkout exists, the CLI MUST run host `ini - Then clone fails structured, the managed dev container is deleted, the workspace `*-ws` volume is deleted, and temps are cleaned up - And a reused pre-existing workspace volume is not deleted -See also: [core.md](core.md) **Up lifecycle** and [lifecycle-hooks.md](lifecycle-hooks.md) for the shared create-path hook matrix; [vscode.md](vscode.md) for postAttach policy. +See also: [core.md](core.md) **Up lifecycle** and [lifecycle-hooks.md](lifecycle-hooks.md) for which hooks run on fresh create; [vscode.md](vscode.md) for postAttach policy. --- diff --git a/specs/core.md b/specs/core.md index 7405b70..d6f6f30 100644 --- a/specs/core.md +++ b/specs/core.md @@ -71,7 +71,7 @@ After parse, the resolver MUST apply this substitution subset anywhere string va | `${localWorkspaceFolderBasename}` | Basename of the workspace root | | `${localEnv:VAR}` | Value of host environment variable `VAR` (empty string if unset, unless a default form is later specified) | | `${containerWorkspaceFolder}` | Resolved container workspace folder path (after `workspaceFolder` resolution) | -| `${devcontainerId}` | Resource identity stem `adev-{base}-{hash12}` (empty resource base → `adev-{hash12}`). **Base** is the sanitized workspace folder basename (`up`) or git URL repo basename (`clone`) only — MUST NOT use config `name`. Hash material matches the product workspace volume (bind: workspace path + config path; volume: normalized git URL + config relative path). MUST NOT be the create `--name` / DNS hostname. Official meaning is unique + stable across rebuilds; this stem is that analogue. | +| `${devcontainerId}` | Resource identity stem `adev-{base}-{hash12}` (empty resource base → `adev-{hash12}`). **Base** is the sanitized workspace folder basename (`up`) or git URL repo basename (`clone`) only — MUST NOT use config `name`. Hash material matches the product workspace volume (bind: workspace path + config path; volume: normalized git URL + config relative path). MUST NOT be the create `--name` / DNS hostname. Official `${devcontainerId}` is unique and stable across rebuilds; this stem is the product's stand-in for that unique, rebuild-stable id. | Unsupported substitution tokens MUST cause a structured error naming the token. Substitution MUST run before runtime admission and mount/port mapping. @@ -377,7 +377,7 @@ The existing known optional families remain warn-and-ignore in default mode: doc **Blocked inputs — Features-aware** - `runArgs` entries not on the runArgs allowlist and not in the registered warn-and-ignore family -- First-class smuggling via runArgs (`-e`, `-u`, `-w`, `-p`, `-v`, …) +- First-class collisions via runArgs (`-e`, `-u`, `-w`, `-p`, `-v`, …) - Docker Compose keys / compose-file driven multi-service config - Unknown top-level dangerous properties; neither `image` nor nested `build`, or both together; invalid Feature option shapes; hostRequirements shortfalls; unsupported substitutions - Top-level `dockerFile` / `dockerfile` / `context` @@ -442,7 +442,7 @@ The existing known optional families remain warn-and-ignore in default mode: doc - When config is validated - Then admission succeeds; the docker-* ref is absent from admitted features; stderr warns naming the feature -#### Scenario: Non-ood features no longer rejected as blanket-unsupported +#### Scenario: Features other than docker-outside-of-docker are no longer rejected as blanket-unsupported - Given `features` with only `ghcr.io/devcontainers/features/node:1` - When config is validated at admission - Then the CLI does not fail with a blanket “features are not supported” error @@ -681,11 +681,11 @@ postAttach is **not** part of create-path delete-on-fail. Settings/open soft-fai | Fresh create-path `up`/`clone`/`rebuild` with well-formed settings | after create-path hooks: settings merge (soft-fail); marker/idempotency rules | | Fresh create-path without settings (and no pending payload) | no settings apply required | | Any path with well-formed extensions, `--vscode` absent | extensions not installed by CLI on that invocation | -| Any path with well-formed extensions, `--vscode` set, marker pending/drift | before open: extensions install (soft-fail; flag gate only — runs even if open later soft-fails); then open; then postAttach only on open success per existing matrix | +| Any path with well-formed extensions, `--vscode` set, marker pending/drift | before open: extensions install (soft-fail; flag gate only — runs even if open later soft-fails); then open; then postAttach only on open success per the postAttach rows above | | Any path with matching marker for full normalized payload | skip redundant settings+extensions apply | | `start` / reuse with loadable config and marker drift | settings repair when applicable; extensions only when `--vscode` is set and still pending (not gated on open success) | -postAttach matrix rows and gating text above remain in force. Customizations apply is **not** part of create-path delete-on-fail and **not** folded into postAttach execution. +The postAttach rows in the lifecycle hook matrix above, and the gating text above, remain in force. Customizations apply is **not** part of create-path delete-on-fail and **not** folded into postAttach execution. Create-path cleanup is unchanged: if any create-path hook fails before the command returns success, the CLI MUST delete the new/created container (extend to onCreate, updateContent, postCreate, and first-create postStart). On `rebuild`, delete-on-fail applies to the **new** container only (workspace/config volumes preserved); eligible hard post-delete failures then offer mode-split recovery. @@ -745,7 +745,7 @@ Create-path cleanup is unchanged: if any create-path hook fails before the comma - When the user runs `adevcontainer up` for that workspace - Then the CLI fails with `config_hash_mismatch` and does not delete the container - And the error hint mentions `adevcontainer rebuild` and managed selection (`--name` or auto) -#### Scenario: rebuild hook matrix row applies +#### Scenario: rebuild runs the fresh-create hook order - Given a managed container being rebuilt with a config carrying initialize plus the four create-path hooks - When `rebuild` runs the fresh create-path on the new container - Then initialize runs on the host, then onCreate → updateContent → postCreate → postStart execute on the new container, and a first create-path hook failure deletes only the new container @@ -1052,7 +1052,7 @@ The repository MUST provide pure JSON capability fixtures used by tests and docs | `Tests/Fixtures/lifecycle.json` | postCreateCommand | | `Tests/Fixtures/lifecycle-hooks.json` | lifecycle hooks | | `Tests/Fixtures/runargs-host.json` | runArgs + hostRequirements | -| `Tests/Fixtures/features-node.json` | OCI Features runner (node only; no docker-ood) | +| `Tests/Fixtures/features-node.json` | OCI Features runner (node only; no docker-outside-of-docker) | | `Tests/Fixtures/features-local.json` | Local path Features runner (sample-a + sample-b) | Fixtures MUST be valid for their capability (no hard-error props such as Compose). They MAY include warn-skip surface when testing that path; ordinary fixtures SHOULD remain free of docker-* / privileged noise. They SHOULD align field styles with `reference/devcontainer.json` where applicable (image family, env keys, mount shapes, ports) while remaining Apple-container-runnable after warn-skips. Existing core fixtures MUST remain valid under lifecycle / runArgs / hostRequirements / Features-aware admission (configs without `features` behave as today). diff --git a/specs/features.md b/specs/features.md index 6c03246..99a841b 100644 --- a/specs/features.md +++ b/specs/features.md @@ -94,8 +94,8 @@ Warnings MUST be actionable via `StatusPrinter.warning` (or equivalent stderr pa - When features are resolved for `up` - Then the CLI does **not** fail solely for privileged/securityOpt; emits a warning; does not apply privileged/securityOpt to create; and MAY still install the feature -#### Scenario: Non-ood feature is not rejected solely as features-unsupported -- Given only `ghcr.io/devcontainers/features/node:1` in `features` (no ood) +#### Scenario: Non-docker-outside-of-docker feature is not rejected solely as features-unsupported +- Given only `ghcr.io/devcontainers/features/node:1` in `features` (no docker-outside-of-docker ref) - When config is validated at the admission layer - Then the CLI does **not** fail with the legacy “any features entry rejected / features runner post-MVP” policy @@ -497,7 +497,7 @@ Privileged / `securityOpt` contributions are warn-stripped and not applied to cr #### Scenario: up finish still has base-image postAttach after remelt - Given Features apply already unioned base-image `postAttach` into the create config - When `up` finish remelts feature postAttach from image metadata that is features-only -- Then the base-image postAttach still runs (remelt unions, does not replace-away) +- Then the base-image postAttach still runs (remelt unions; it does not drop the base-image hook) #### Scenario: devcontainer.metadata label merge when present - Given a base image with a parseable `devcontainer.metadata` label @@ -528,7 +528,7 @@ Privileged / `securityOpt` contributions are warn-stripped and not applied to cr ### Requirement: Feature postStart remelt on resume -On every path that MUST run `postStartCommand` after a successful start of a previously stopped container (`up` start-stopped and bare `adevcontainer start` in bind and volume modes), the CLI MUST remelt feature-contributed `postStart` commands for that invocation. The CLI MUST run the config `postStartCommand` when present, then feature-contributed postStart commands, in the same merge/order spirit as create-path feature lifecycle hooks. +On every path that MUST run `postStartCommand` after a successful start of a previously stopped container (`up` start-stopped and bare `adevcontainer start` in bind and volume modes), the CLI MUST remelt feature-contributed `postStart` commands for that invocation — re-merge and run them again on this start, not only on the original create. The CLI MUST run the config `postStartCommand` when present, then feature-contributed postStart commands, in the same merge/order spirit as create-path feature lifecycle hooks. Resume MUST NOT drop feature-contributed postStart solely because the container was created earlier. Feature onCreate / updateContent / postCreate MUST remain create-path only. A non-zero remelted feature postStart on resume MUST fail the command and MUST NOT delete the container. @@ -591,7 +591,7 @@ The repository MUST provide: | Path | Content | |------|---------| -| `Tests/Fixtures/features-node.json` | Valid image-based config with **only** a non-ood OCI feature suitable for Node (e.g. `ghcr.io/devcontainers/features/node` with a pinned tag) and options if needed | +| `Tests/Fixtures/features-node.json` | Valid image-based config with **only** an OCI feature that is not docker-outside-of-docker, suitable for Node (e.g. `ghcr.io/devcontainers/features/node` with a pinned tag) and options if needed | | `Tests/Fixtures/features-local.json` | Valid image-based config with local path features `./.devcontainer/features/sample-a` and `sample-b` (options as needed) | | `Tests/Fixtures/features-sample/` | On-disk sample feature packages (`sample-a`, `sample-b`, `sample-privileged`) for unit + local E2E | diff --git a/specs/lifecycle-hooks.md b/specs/lifecycle-hooks.md index b591ce1..72e040f 100644 --- a/specs/lifecycle-hooks.md +++ b/specs/lifecycle-hooks.md @@ -2,7 +2,7 @@ ## Purpose -Lifecycle hook surface for `initializeCommand` through `postAttachCommand` (string | argv | object-map forms, parallel object-map, create-path order, resume/reuse behavior, delete-on-fail), plus `waitFor`, `userEnvProbe`, and `shutdownAction`. postAttach execution lives in [vscode.md](vscode.md); create-path matrices on `up` also appear in [core.md](core.md). +Lifecycle hook surface for `initializeCommand` through `postAttachCommand` (string | argv | object-map forms, parallel object-map, fresh-create (create-path) hook order, resume/reuse behavior, delete-on-fail), plus `waitFor`, `userEnvProbe`, and `shutdownAction`. postAttach execution lives in [vscode.md](vscode.md); the hooks that run on fresh create, resume, and reuse of `up` are also specified in [core.md](core.md) **Up lifecycle**. ## Requirements @@ -310,4 +310,4 @@ This requirement MUST NOT change hook order, admitted forms, fail/delete-on-fail - When the CLI executes that hook - Then `==> Running …` status lines are not printed and the hook’s output still appears on host stderr as framed internal tool lines -See also: [core.md](core.md) **Up lifecycle** for the create/reuse/start path matrix (including postAttach and vscode customizations rows); [vscode.md](vscode.md) for **postAttachCommand policy (CLI-only)**; [features.md](features.md) **Feature postStart remelt on resume** and **Features progress status lines** for remelt and StatusPrinter / QUIET / `--json` norms; [terminal-output.md](terminal-output.md) for framing/color/QUIET presentation. +See also: [core.md](core.md) **Up lifecycle** for which hooks run on create, reuse, and start (including the postAttach and vscode customizations rows); [vscode.md](vscode.md) for **postAttachCommand policy (CLI-only)**; [features.md](features.md) **Feature postStart remelt on resume** and **Features progress status lines** for re-merging feature postStart on resume and for StatusPrinter / QUIET / `--json` norms; [terminal-output.md](terminal-output.md) for framing/color/QUIET presentation. diff --git a/specs/runargs-host.md b/specs/runargs-host.md index 61ed755..f041d69 100644 --- a/specs/runargs-host.md +++ b/specs/runargs-host.md @@ -55,7 +55,7 @@ Each skipped entry MUST emit a stderr warning naming the entry and that it is ig **Still hard-error** -- First-class smuggling flags (`-e`/`-u`/`-w`/`-p`/`-v`/… listed above) +- First-class collisions: runArgs flags for first-class properties (`-e`/`-u`/`-w`/`-p`/`-v`/… listed above); hard-error even if that property is unset - Any other flag or bare token not consumed by the allowlist rules above - Incomplete valued forms (e.g. bare `--cap-add` with no name) @@ -66,7 +66,7 @@ Errors MUST name the offending `runArgs` entry and state allowlist or first-clas - When config is resolved and a create request is built - Then admission succeeds and create argv includes `--init` and the corresponding `--cap-add` / `--cap-drop` forms supported by the runtime mapper -#### Scenario: Wave A+B allowlisted flags admit and map +#### Scenario: shm-size, dns, tmpfs, named network, and --rosetta/--ssh/--read-only admit and map - Given `runArgs` including `--shm-size=64m`, `--dns=8.8.8.8`, `--tmpfs=/tmp:rw`, `--network=mynet`, `--rosetta`, `--ssh`, `--read-only` - When config is resolved and a create request is built - Then admission succeeds; create argv includes `--shm-size`/`64m`, `--dns`/`8.8.8.8`, `--tmpfs`/`/tmp` (opts stripped), `--network`/`mynet`, `--rosetta`, `--ssh`, `--read-only` @@ -186,7 +186,7 @@ The repository MUST provide pure JSON fixtures for lifecycle, runArgs, and hostR | `Tests/Fixtures/lifecycle-hooks.json` | `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`; MAY include admitted `postAttachCommand` | | `Tests/Fixtures/runargs-host.json` | Allowlisted `runArgs` and parseable `hostRequirements` (`memory` and/or `cpus`) | -Ordinary fixtures MUST NOT include Compose or unknown/first-class-smuggling runArgs (hard-error). Privileged/device family entries are valid warn-skip inputs (see warn-skip scenarios); keep default fixtures free of them unless exercising that path. They SHOULD remain Apple-container-runnable for optional integration tests. +Ordinary fixtures MUST NOT include Compose, unknown runArgs, or runArgs flags for first-class properties (hard-error even if that property is unset). Privileged/device family entries are valid warn-skip inputs (see warn-skip scenarios); keep default fixtures free of them unless exercising that path. They SHOULD remain Apple-container-runnable for optional integration tests. #### Scenario: Lifecycle / runArgs / hostRequirements fixtures admit - Given each lifecycle / runArgs / hostRequirements fixture file diff --git a/specs/terminal-output.md b/specs/terminal-output.md index 7837189..12a090f 100644 --- a/specs/terminal-output.md +++ b/specs/terminal-output.md @@ -239,7 +239,7 @@ On `clone`, after the container is created and started, the **in-container full 4. Keep host stdout pure under `--json`. 5. Under `ADEVCONTAINER_QUIET=1`, still emit framed tool body; only the product populate status/info lines are silenced. -This requirement extends presentation only; populate auth matrix, verify `.git`, and failure cleanup remain as in [clone.md](clone.md). +This requirement extends presentation only; populate auth by URL scheme, verify `.git`, and failure cleanup remain as in [clone.md](clone.md) **In-container full clone populate (auth by URL scheme)**. #### Scenario: Clone populate streams live framed tool output - Given a clone path whose in-container git clone prints progress lines to stdout/stderr and quiet mode unset diff --git a/wiki/architecture.md b/wiki/architecture.md index bd0d00f..82af200 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -68,10 +68,10 @@ Volume mode exists for better metadata I/O (git status, node_modules, many small | `doctor` | Host/runtime readiness; plugin layout. Checks-only (no restage). Missing plugin: PATH `adevcontainer plugin --install` (`sudo` when needed) — never `container dev plugin --install`. | | `plugin --install` / `plugin --uninstall` | Install or remove the Apple plugin layout. `--install` writes an absolute symlink at `bin/dev` (not a Mach-O copy). `--uninstall` removes only `bin/dev` and `config.toml`. | | `up` | **Bind-mode** only: resolve config from host workspace, create/start/reuse; ensure named volumes; workspace bind; Features; lifecycle hooks in scope. Optional `--vscode` after success (see [VS Code flow](#vs-code-flow)). Reuse only when stamped config hash matches; mismatch → fail `config_hash_mismatch` (remediate with `rebuild`). Eligible bring-up failure with an editable host `devcontainer.json` → [bring-up recovery](#bring-up-recovery). Author synchronization is not part of `up` fresh-create. | -| `clone [--resume]` | **Volume-mode** workspace (VS Code clone-in-volume analogue): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot. Non-Azure unchanged — clone: local `credential.helper store` + pathless approve; up/rebuild: append-only global product helper, inherited helpers kept. `dev.azure.com` only: empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev`, and `credential.https://dev.azure.com.useHttpPath true`. Product helper matches protocol+host and ignores path. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM). No GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | +| `clone [--resume]` | **Volume-mode** workspace (the product's volume-mode counterpart of VS Code clone-in-volume): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot. Non-Azure unchanged — clone: local `credential.helper store` + pathless approve; up/rebuild: append-only global product helper, inherited helpers kept. `dev.azure.com` only: empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev`, and `credential.https://dev.azure.com.useHttpPath true`. Product helper matches protocol+host and ignores path. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM). No GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | | `rebuild [--name]` | **Forced-rebuild** path (realized in domain specs; archive [`20260810-rebuild`](../specs/changes/archive/20260810-rebuild/)): managed selection (`--name` / auto-single / picker); read **current** stamped `devcontainer.json` before any delete and capture the existing workspace-local Git author identity as the sole source of truth (bind: host workspace; volume: old container workspace); after config/host/Features succeed → container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `*-ws` (rebuild never deletes `*-ws`) and `${devcontainerId}` volumes; after replacement preparation, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; no host config or other container users changed; missing/incomplete → no invented value. User-literal sources stay as written (data preserved, never re-clone). Foreign occupant of the new name: do not delete selected; TTY Y/n + new name. Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: a name-indexed resume is published before delete, and `rebuild --name` opens that session when the container is gone and no helper exists, reusing the stamped `*-ws` without creating a blank workspace volume. A listed marked helper still wins. | | `list [--json]` | Managed containers only (`devcontainer.managed=adevcontainer`) | -| `start [--name]` | Start a managed stopped container via `--name` or interactive picker. **Real start** (stopped→running, bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt → CLI-attach `postAttachCommand`. **Already-running start:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. Optional `--vscode` is **open only**. postAttach loads config from labels (bind: host `local_folder`+`config_file`; volume: in-container config path) and merges feature postAttach from image `devcontainer.metadata` (load errors → treat absent, do not fail start). Start failure recovery delegates to `rebuild --name` — does **not** re-run start or open an editor | +| `start [--name]` | Start a managed stopped container via `--name` or interactive picker. **Real start** (stopped→running, bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt (re-merge and run feature-contributed postStart on this start) → CLI-attach `postAttachCommand`. **Already-running start:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. Optional `--vscode` is **open only**. postAttach loads config from labels (bind: host `local_folder`+`config_file`; volume: in-container config path) and merges feature postAttach from image `devcontainer.metadata` (load errors → treat absent, do not fail start). Start failure recovery delegates to `rebuild --name` — does **not** re-run start or open an editor | | `exec [--name]` | Run command/shell in running managed container (`-it` / empty cmd → interactive TTY, default `bash`). Selection: `--name` or picker (no `-w`). User/workdir from labels `devcontainer.remote_user` / `devcontainer.workspace_folder` when stamped (new creates always stamp non-empty `remote_user` incl. `root`; empty = legacy — see [Connection user](#connection-user)) | | `stop [--name]` | Stop managed container (`--name` or picker; no `-w`) | | `delete [--name]` | Remove **container only** (`--name` or picker; no `-w`) | @@ -146,14 +146,14 @@ Effective user for `exec`, lifecycle hooks, and VS Code attach defaults (not alw - `forwardPorts` → publish ports on the Apple container (IDE auto-forward not guaranteed). - `portsAttributes` stored/surfaced as metadata where useful. -- Lifecycle hooks: in-container via `container exec` except host `initializeCommand`. Each hook admits **string** | **argv** | **object map** `name → string|argv` (empty `{}` no-op); map entries run **in parallel** (stage succeeds only if every entry exits 0). `waitFor` default `updateContentCommand`. `userEnvProbe` / `shutdownAction` admitted (`stopCompose` fail-closed; explicit `stop` always stops). Detail: [cli-runtime-boundary — Lifecycle](conventions/cli-runtime-boundary.md#lifecycle-execution-hook-matrix). Contract: [`specs/lifecycle-hooks.md`](../specs/lifecycle-hooks.md); archive [`20260814-align-official-lifecycle`](../specs/changes/archive/20260814-align-official-lifecycle/). Matrix: +- Lifecycle hooks: in-container via `container exec` except host `initializeCommand`. Each hook admits **string** | **argv** | **object map** `name → string|argv` (empty `{}` no-op); map entries run **in parallel** (stage succeeds only if every entry exits 0). `waitFor` default `updateContentCommand`. `userEnvProbe` / `shutdownAction` admitted (`stopCompose` fail-closed; explicit `stop` always stops). Detail: [cli-runtime-boundary — Lifecycle](conventions/cli-runtime-boundary.md#lifecycle-execution-hook-matrix). Contract: [`specs/lifecycle-hooks.md`](../specs/lifecycle-hooks.md); archive [`20260814-align-official-lifecycle`](../specs/changes/archive/20260814-align-official-lifecycle/). Which hooks run: | Path | Hooks | |------|--------| | Fresh create (`up` bind, `clone` volume, or `rebuild` replacement) | host `initializeCommand` (host path when present; volume/clone-origin `rebuild` with no host workspace still runs from a temp root with the live guest `.devcontainer/` and root `.devcontainer.json` if that is the config; temp removed after the hook; `./scripts/…` not required) → `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand`; `waitFor` default `updateContentCommand` (Ready/open/postAttach after named stage); delete container if any create-path hook fails (`up`/`clone` may enter [bring-up recovery](#bring-up-recovery); `rebuild` hard post-delete may enter rebuild recovery) | | Reuse running (`up` only when config hash matches) | host `initializeCommand` when host workspace exists; no create-path hooks; settings+extensions apply on marker drift (**not** `--vscode`-gated); CLI-attach postAttach (feature hooks mergeable from image metadata) | | Config hash mismatch (`up`) | fail `config_hash_mismatch` — no delete or replacement; use `rebuild` | - | Bind start-stopped (`up`, hash match) | host `initializeCommand` → `postStartCommand` + feature remelt; then settings+extensions apply if pending (**not** `--vscode`-gated); CLI-attach postAttach; postStart failure fails `up` but does **not** delete | + | Bind start-stopped (`up`, hash match) | host `initializeCommand` → `postStartCommand` + feature remelt (re-merge and run feature-contributed postStart on this start); then settings+extensions apply if pending (**not** `--vscode`-gated); CLI-attach postAttach; postStart failure fails `up` but does **not** delete | | Bare `start` | **Real start:** host `initializeCommand` (skip+warn if no host path) → `postStartCommand` + feature remelt → CLI-attach postAttach. **Already-running:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. Config from labels for postAttach only; feature hooks from image metadata | | `customizations.vscode` | **CLI apply** (config-file v1): settings+extensions after create-path hooks on `up`/`clone`/`rebuild` and on `up` reuse / `up` start-stopped drift (**not** gated on `--vscode` or open); **not** on `start`; soft-fail; marker idempotency — see [VS Code flow](#vs-code-flow) | | `postAttachCommand` | **CLI attach** on `up`/`clone`/`rebuild`/real `start` after waitFor (not `--vscode`-gated; open success/soft-fail MUST NOT skip). Already-running `start`: **RUNS** only after successful `--vscode` open; **SKIP** (+ status when any present) if flag absent or open soft-fails. Order on apply-commands: apply → open → postAttach (postAttach still runs if open soft-fails). Non-zero → fail command, **keep** container. Soft-fail apply ≠ postAttach fail-keep. Contract: [`specs/vscode.md`](../specs/vscode.md) + active [`vscode-customizations-up-clone-rebuild`](../specs/changes/vscode-customizations-up-clone-rebuild/); archive [`20260814-align-official-lifecycle`](../specs/changes/archive/20260814-align-official-lifecycle/); open archive: [`specs/changes/archive/20260808-vscode-open-flag/`](../specs/changes/archive/20260808-vscode-open-flag/); apply archive: [`specs/changes/archive/20260808-vscode-customizations-apply/`](../specs/changes/archive/20260808-vscode-customizations-apply/) | diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 6ff2ddc..0bb7c2a 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -32,7 +32,7 @@ Parse these paths from machine JSON (not human tables). Shape documented against - Resolver owns JSONC + substitution subset: `${localWorkspaceFolder}`, `${localWorkspaceFolderBasename}`, `${localEnv:*}`, `${containerWorkspaceFolder}`, `${devcontainerId}` (see below). Unknown tokens → structured error. - Resolver emits a runtime request DTO; runtime maps DTO → argv + env for `container`. -- Unsupported props/flags fail in resolver or runtime admission — **fail closed**, no drop-on-floor. +- Unsupported props/flags fail in resolver or runtime admission — **fail closed**; do not silently discard them. - Nested `build` xor `image` is representable. Compose and top-level `dockerFile`/`dockerfile`/`context` stay blocked. Contract: [`specs/core.md`](../../specs/core.md). ### Dockerfile image (nested `build`) @@ -91,7 +91,7 @@ Fixtures may use directory binds already (`~/.kube`); configs that still use fil - `--cpus`/`-c`, `--memory`/`-m` — **merge into** create `-c`/`-m` (no duplicate tokens); hostRequirements wins when set for that dimension - `--network=NAME` — **named networks only** (host/bridge/none/container:* warn-skipped) - `--rosetta`, `--ssh`, `--read-only` (rootfs flag, not volume `:ro`; selects ownership helper — see [ownership](#named-volumes-ensure--reuse--ownership)) -- **Not via runArgs** (first-class props; hard-error if smuggled): `-e`/`-u`/`-w`/`-p`/`-v`/`--mount`/`--name`/`--label`/`-i`/`-t`/`-d`/`--rm`/`--entrypoint`. +- **Not via runArgs** (first-class properties; hard-error even if the matching property is unset): `-e`/`-u`/`-w`/`-p`/`-v`/`--mount`/`--name`/`--label`/`-i`/`-t`/`-d`/`--rm`/`--entrypoint`. - **Warn-skip** (not applied): `--privileged`, `--device=…` (incl. `/dev/net/tun`), `--security-opt`, `--gpus`, `--ipc`, `--pid`, `--userns`, `--cgroupns`, `--hostname`, `--add-host`, `--sysctl`, `--group-add`, `--runtime`, Docker-only network modes. See [0003](../decisions/0003-warn-skip-apple-incompatibles.md). - Unknown or incomplete entries (e.g. bare `--cap-add` with no name) → structured error naming the entry. @@ -260,7 +260,7 @@ After ownership, before create-path hooks, `up` fresh create (bind) and `rebuild - `list [--json]`: client-side filter to `devcontainer.managed=adevcontainer` only. - `start` / `exec` / `stop` / `delete` / `purge` / `rebuild` / `inspect`: `--name` or picker among managed; no host workspace path required. -- `start`: runtime start of a managed container. **Real start** (bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt → CLI-attach postAttach. **Already-running:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. **No** create-name occupancy classification. Start failure: TTY recovery delegates to `rebuild --name` (does **not** re-run start or open an editor); non-TTY/`--json`/decline → original error + hint `adevcontainer rebuild --name `. +- `start`: runtime start of a managed container. **Real start** (bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt (re-merge and run feature-contributed postStart on this start) → CLI-attach postAttach. **Already-running:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. **No** create-name occupancy classification. Start failure: TTY recovery delegates to `rebuild --name` (does **not** re-run start or open an editor); non-TTY/`--json`/decline → original error + hint `adevcontainer rebuild --name `. - `exec`: user/workdir from labels `devcontainer.remote_user` / `devcontainer.workspace_folder` when set (both modes stamp workdir; new creates always stamp non-empty `remote_user` incl. `root`; empty label = legacy omit `-u` — see [Connection user](#connection-user-remoteuser--containeruser)). ### InteractivePicker (multi-container) @@ -368,7 +368,7 @@ Apple `container` does **not** expand `${PATH}` / `$PATH` in env values. Product ## Progress / tee -Presentation stack (StatusPrinter + TerminalStyle, tool `| ` framing, QUIET/color matrix, connectionHint info weight, clone populate `streamOutput`): **[terminal-output.md](terminal-output.md)**. Runtime-boundary facts that stay here: +Presentation stack (StatusPrinter + TerminalStyle, tool `| ` framing, QUIET and color rules, connectionHint info weight, clone populate `streamOutput`): **[terminal-output.md](terminal-output.md)**. Runtime-boundary facts that stay here: - Long ops emit product phases on stderr (pull/create/start/stop/delete/volume create; Features Resolving/Fetching/Building/Reusing; build.rosetta when changing; lifecycle `==> Running `; related steps). StatusPrinter only — not hook/tool body I/O. - After successful `up`/`clone`/`start`/`rebuild` without originating `--vscode`, connection hints (info, not `==> `) on stderr; `--vscode` suppresses both; never on stdout/`--json`. diff --git a/wiki/decisions/0002-reject-docker-ood-privileged-tun.md b/wiki/decisions/0002-reject-docker-ood-privileged-tun.md index 642ed7f..8c20cd5 100644 --- a/wiki/decisions/0002-reject-docker-ood-privileged-tun.md +++ b/wiki/decisions/0002-reject-docker-ood-privileged-tun.md @@ -2,7 +2,7 @@ ## Status -**Superseded in part** by [0003](0003-warn-skip-apple-incompatibles.md). Optional incompatibles (docker-* features, privileged/device/security runArgs, feature privileged/securityOpt metadata) are now **warn-skip**. Compose, unknown runArgs, and first-class smuggling remain fail-closed as below. +**Superseded in part** by [0003](0003-warn-skip-apple-incompatibles.md). Optional incompatibles (docker-* features, privileged/device/security runArgs, feature privileged/securityOpt metadata) are now **warn-skip**. Compose, unknown runArgs, and first-class collisions (runArgs flags for first-class properties, even if that property is unset) remain fail-closed as below. ## Context diff --git a/wiki/decisions/0003-warn-skip-apple-incompatibles.md b/wiki/decisions/0003-warn-skip-apple-incompatibles.md index e9c1ea7..4c7e79d 100644 --- a/wiki/decisions/0003-warn-skip-apple-incompatibles.md +++ b/wiki/decisions/0003-warn-skip-apple-incompatibles.md @@ -24,7 +24,7 @@ Multi-platform `devcontainer.json` files often include Docker-oriented features - Docker Compose keys - Unknown runArgs (not allowlisted and not in the warn-skip family) -- First-class smuggling via runArgs (`-e`, `-u`, `-w`, `-p`, `-v`, …) +- First-class collisions via runArgs (`-e`, `-u`, `-w`, `-p`, `-v`, …) - Unknown top-level dangerous properties, missing `image`, invalid feature option shapes, hostRequirements shortfalls, unsupported substitutions **Semantics:** diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index b791ff2..ab70940 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -33,7 +33,7 @@ Facts that constrain the CLI. Not a full Apple container manual — only gaps th | Storage | Host directory (virtiofs → APFS) | Named volume `adev-*-ws` (virtio-blk → raw ext4 `volume.img`) | | Identity hash | workspace path + config path | normalized git URL + config relpath | | `local_folder` label | real host path | `volume://…` | -| Start hooks | real start: host `initializeCommand` (if host path) + `postStart` + feature remelt + CLI-attach postAttach | same; volume start without host path skips initialize + warns | +| Start hooks | real start: host `initializeCommand` (if host path) + `postStart` + feature remelt (re-merge and run feature-contributed postStart on this start) + CLI-attach postAttach | same; volume start without host path skips initialize + warns | | Auth for git | **HTTPS:** bind-mode create paths (`up` fresh create; `rebuild` bind) seed a username-agnostic POSIX-sh helper in the effective connection user's HOME before create-path hooks — HOME is resolved from the effective user, the helper is 0700, its private store is 0600. Non-Azure keeps an append-only global `credential.helper` (inherited helpers kept) and pathless approve. `dev.azure.com` only gets a URL-scoped override — empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev` — and `credential.https://dev.azure.com.useHttpPath true`. Helper `get` matches protocol+host and ignores path and the queried username, returning that username (or the stored username when the query has none) with the stored password, covering sibling repos. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM) into the guest. Host acquisition uses `git credential fill` (`ADEVCONTAINER_GIT_TOKEN` / `gh auth token` fallbacks); seeded credential material enters the guest via stdin only, never argv or env. Silent skip: host git missing / no repo or remotes / fill nil (public stays anonymous). Soft-fail warn. **Not seeded:** SSH remotes; non-create paths (`up` reuse, `up` start-stopped, bare `start`) | **SSH:** `SSH_AUTH_SOCK` + `create --ssh`. **HTTPS:** clone's separate populate uses host `git credential fill` one-shot. Non-Azure: local `credential.helper store` + pathless approve. `dev.azure.com`: the URL-scoped helper and `useHttpPath` in the bind cell, not pathless `credential.helper store`. No GCM-in-guest; no host `~/.git-credentials` mount; no host `credential.helper` copy; no PAT CLI primary UX | | Populate | N/A (host tree) | **In-container full `git clone`** + verify `.git` (host = config-only sparse/shallow only; no host full+tar happy path) | @@ -68,9 +68,9 @@ The mounts-ports fixture uses a `~/.kube` **directory** bind; `reference/devcont ### Failed rebuild recovery (mode-split) -Rebuild recovery is **mode-split**. Shared rules: same hard post-delete trigger matrix and TTY/non-TTY branching; pre-delete config/host/Features failures and postAttach/settings/open failures never enter recovery. +Rebuild recovery is **mode-split**. Shared rules: same hard post-delete triggers and TTY/non-TTY branching; pre-delete config/host/Features failures and postAttach/settings/open failures never enter recovery. -**Shared hard post-delete matrix:** replacement `create` failure, replacement `start` failure, or create-path hook failure. Excluded (terminal, no recovery): volume ensure, pre-delete config/host/Features, `postAttachCommand`, customizations apply, final verification, and other non-retryable errors. Retryable hard failures are runtime, lifecycle, and post-create only. +**Shared hard post-delete triggers:** replacement `create` failure, replacement `start` failure, or create-path hook failure. Excluded (terminal, no recovery): volume ensure, pre-delete config/host/Features, `postAttachCommand`, customizations apply, final verification, and other non-retryable errors. Retryable hard failures are runtime, lifecycle, and post-create only. **TTY / non-TTY (both modes):** After hard post-delete on TTY: structured error, then `Open the recovery editor now? [Y/n]` (default **Y**). Decline/EOF retains the marked helper/session when one is listed, the published volume name index, or BindRecoveryResume, and prints retry guidance (no editor). A live helper is not required to retain volume resume. Named `rebuild` retry skips the prompt and opens the editor before apply/write. Non-TTY/JSON unchanged: structured retained details, no editor/prompt; named retry applies retained temp. TTY loops on invalid config once the editor is open. Progress must show "entering recovery" so create-path/postCreate failure is not mistaken for a hang. TTY editor launch uses **InteractiveProcessRunner** `tcsetpgrp` + `SIGCONT` (inherit-stdio alone → `nano`/`vi` `STAT=T` hang). Detail: [cli-runtime-boundary](../conventions/cli-runtime-boundary.md). @@ -185,8 +185,8 @@ CLI apply is a **seed** (download VSIX, unpack, registry upsert) — not full ga - **Hard errors** for unknown-dangerous props/flags and Compose — never silent ignore. Known optional Apple-incompatibles **warn-skip**. - **`forwardPorts`**: map to publish ports; do not promise IDE auto-forward behavior. - **`portsAttributes`**: metadata only; no IDE auto-forward. -- **Lifecycle**: in-container via `container exec` except host `initializeCommand` (up/clone/real start when host workspace exists; volume/clone-origin `rebuild` with no host workspace still runs — not skip; volume start without host path skips+warns). Hook matrix: create-path full order on `up`/`clone`/`rebuild`; real start (bind+volume) `postStart` + feature remelt; already-running start no postStart; reuse none for create-path; **postAttach** = CLI attach on `up`/`clone`/`rebuild`/real start (not `--vscode`-gated); already-running start postAttach only after successful `--vscode` open; fail-keep; `start` loads config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`. Forms: string | argv | named object map (**parallel**; stage succeeds only if every entry exits 0). `waitFor` default `updateContentCommand`. `userEnvProbe` / `shutdownAction` admitted (`stopCompose` fail-closed; explicit stop always stops). Exec must expand `containerEnv` PATH refs (same as create) or login-shell hooks fail (`id`/`bash` not found). Feature `entrypoint` is create PID 1 wrap (not a hook; image `ENTRYPOINT`/`CMD` still discarded). Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). Contract: [`specs/features.md`](../../specs/features.md); archive [`20260918-feature-entrypoint-keep-alive`](../../specs/changes/archive/20260918-feature-entrypoint-keep-alive/). -- **`runArgs`**: allowlist (`--init`, `--cap-add`/`--cap-drop`, …); privileged/tun/device/security family **warn-skip**; unknown and first-class smuggling flags fail closed. +- **Lifecycle**: in-container via `container exec` except host `initializeCommand` (up/clone/real start when host workspace exists; volume/clone-origin `rebuild` with no host workspace still runs — not skip; volume start without host path skips+warns). Which hooks run: create-path full order on `up`/`clone`/`rebuild`; real start (bind+volume) `postStart` + feature remelt (re-merge and run feature-contributed postStart on this start); already-running start no postStart; reuse none for create-path; **postAttach** = CLI attach on `up`/`clone`/`rebuild`/real start (not `--vscode`-gated); already-running start postAttach only after successful `--vscode` open; fail-keep; `start` loads config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`. Forms: string | argv | named object map (**parallel**; stage succeeds only if every entry exits 0). `waitFor` default `updateContentCommand`. `userEnvProbe` / `shutdownAction` admitted (`stopCompose` fail-closed; explicit stop always stops). Exec must expand `containerEnv` PATH refs (same as create) or login-shell hooks fail (`id`/`bash` not found). Feature `entrypoint` is create PID 1 wrap (not a hook; image `ENTRYPOINT`/`CMD` still discarded). Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). Contract: [`specs/features.md`](../../specs/features.md); archive [`20260918-feature-entrypoint-keep-alive`](../../specs/changes/archive/20260918-feature-entrypoint-keep-alive/). +- **`runArgs`**: allowlist (`--init`, `--cap-add`/`--cap-drop`, …); privileged/tun/device/security family **warn-skip**; unknown flags and runArgs flags for first-class properties fail closed, even if that property is unset. - **`hostRequirements`**: preflight — fail on memory/cpus shortfall; map requested limits to create `-m`/`-c` when host OK; warn unsupported `gpu`; fail on unparseable/unknown keys. - **Features**: OCI + local path fetch/load + derived image build on `up`/`clone`/`rebuild` (see [cli-runtime-boundary](../conventions/cli-runtime-boundary.md)); **FROM** product Dockerfile tag when nested `build`, else config `image`. **warn-skip** `docker-outside-of-docker`, `docker-in-docker`, `docker-from-docker` and privileged/securityOpt metadata ([0003](../decisions/0003-warn-skip-apple-incompatibles.md)). Install as root, restore base USER; derived LABEL unions base-image + feature lifecycle; `recipeVersion` `"7"`. Metadata `containerEnv` → Dockerfile `ENV` before install RUN; options + user keys on RUN prefix — [Features install containerEnv](#features-install-containerenv). Runtime create/exec still config-wins. - **Connection user**: local `remoteUser` → local `containerUser` → image metadata last non-empty remote/container user → OCI USER → root (chain unchanged). **Create `-u`:** explicit `containerUser` if set; else non-root connection user; else omit when root — Apple attach ignores nameConfig `remoteUser` / uses container default. Successful create always stamps non-empty `devcontainer.remote_user` (incl. `root`; empty = legacy only). Archive: [`specs/changes/archive/20260811-align-remote-user-resolution/`](../../specs/changes/archive/20260811-align-remote-user-resolution/).