Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion conformance/registry/behaviors/observable-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,17 @@
"spec": "unspecified",
"reference": "divergent",
"decision": "intentional-divergence",
"notes": "Classified as intentional ONLY because the observable behavior was measured equal, not because the difference looks cosmetic. `docker stop`: deacon 245 ms (single-container) / 138 ms (compose), reference 215 ms; exit code 0 on both sides. deacon's form additionally keeps the BusyBox fallback (`sleep infinity` is rejected on Alpine) and a PATH prefix that survives a feature rewriting PATH. An EARLIER version of this same field was recorded as intentional on the assumption that a keep-alive cannot matter behaviorally \u2014 that assumption was false and hid a 10,258 ms `docker stop` stall with a SIGKILL exit (fixed in e243921, T114). The difference between then and now is that the equivalence is measured."
"notes": "Classified as intentional ONLY because the observable behavior was measured equal, not because the difference looks cosmetic. `docker stop`: deacon 245 ms (single-container) / 138 ms (compose), reference 215 ms; exit code 0 on both sides. deacon's form additionally keeps the BusyBox fallback (`sleep infinity` is rejected on Alpine) and a PATH prefix that survives a feature rewriting PATH. An EARLIER version of this same field was recorded as intentional on the assumption that a keep-alive cannot matter behaviorally — that assumption was false and hid a 10,258 ms `docker stop` stall with a SIGKILL exit (fixed in e243921, T114). The difference between then and now is that the equivalence is measured."
},
{
"id": "bhv-container-metadata-label-authored",
"area": "observable-state",
"statement": "The `devcontainer.metadata` label stamped on a created container records the AUTHORED configuration fragments with their variable templates intact (`${localWorkspaceFolder}`, `${containerWorkspaceFolder}`, `${localEnv:VAR}`), while the substituted values are applied to the container itself; reading the label back on the `--container-id` path re-substitutes only the tokens resolvable without a workspace (`${localEnv:VAR}` / `${env:VAR}`), leaving workspace- and container-relative tokens literal.",
"applicability": [],
"spec": "conformant",
"reference": "aligned",
"decision": "follow-spec",
"notes": "Spec-mandated, not merely reference parity: image-metadata.md's Merge Logic closes with \"Variables in string values will be substituted at the time the value is applied\" (clu-image-metadata-variables-in-string-values-will-be-substituted-a-desc-31cd8289), so the recorded form is the template and substitution belongs to application. deacon violated this until T115: `up::merged_config::config_metadata_entry` ran on the already-substituted config, baking the recording machine's absolute path into container metadata and making the label wrong for any later reader. Measured against pinned oracle 0.87.0 on fx-state-single-container: deacon wrote \"source=/tmp/.../ws/sib,target=/workspaces/sib,type=bind\" where the reference wrote \"source=${localWorkspaceFolder}/sib,...\"; a probe fixture confirmed the reference stamps templates for `mounts`, `containerEnv`, `remoteEnv` and `postCreateCommand` alike while applying the substituted `containerEnv` value to `Config.Env`. Normalization could not paper over it — tokenizing deacon's side yields `<WORKSPACE>/sib`, a different FORM — so it was fixed by threading the pre-substitution config (ConfigLoadResult::raw_config, the reference's SubstitutedConfig.raw) to the stamp site. The read-back half was measured too: `devcontainer exec --container-id` over a labelled container prints `LWF=[${localWorkspaceFolder}] LENV=[/home/vscode]`, so deacon applies the same host-env-only pass rather than trading one divergence for another. Backed by case-state-single-container / case-state-mount-variety / case-state-dockerfile-nonroot, which reported `diverge` on this path until the fix."
},
{
"id": "bhv-state-container-parity",
Expand Down
139 changes: 71 additions & 68 deletions conformance/registry/cases.json

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion conformance/registry/sources/observed.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"inventory": "observed",
"revision": "rev-oracle-0-87-0",
"locator": "up/container-labels",
"summary": "On the same fixture (fx-up-basic, image alpine:3.19), `docker inspect .Config.Labels` shows the reference CLI setting exactly three labels \u2014 devcontainer.metadata, devcontainer.local_folder, devcontainer.config_file \u2014 while deacon sets those three plus five it alone defines: devcontainer.configHash, devcontainer.config_name, devcontainer.name, devcontainer.source (value \"deacon\"), devcontainer.workspaceHash. devcontainer.metadata is byte-identical between the two; local_folder and config_file differ only by each side's own workspace path.",
"summary": "On the same fixture (fx-up-basic, image alpine:3.19), `docker inspect .Config.Labels` shows the reference CLI setting exactly three labels — devcontainer.metadata, devcontainer.local_folder, devcontainer.config_file — while deacon sets those three plus five it alone defines: devcontainer.configHash, devcontainer.config_name, devcontainer.name, devcontainer.source (value \"deacon\"), devcontainer.workspaceHash. local_folder and config_file differ only by each side's own workspace path. devcontainer.metadata is byte-identical ON THIS FIXTURE only because its bare config contributes no picked property, so both sides stamp \"[]\"; its CONTENT is a separate claim, recorded by bhv-container-metadata-label-authored, and it was NOT in agreement until T115.",
"behaviors": [
"bhv-container-identity-labels"
]
Expand All @@ -41,6 +41,16 @@
"bhv-container-keepalive-command"
]
},
{
"id": "src-obs-container-metadata-label-authored",
"inventory": "observed",
"revision": "rev-oracle-0-87-0",
"locator": "up/container-metadata-label",
"summary": "On fx-state-single-container the reference stamps `devcontainer.metadata` as [{\"mounts\":[\"source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind\"],\"containerEnv\":{\"SC_ENV\":\"yes\"}}] — templates intact — where deacon stamped the substituted absolute path. A dedicated probe fixture extended the measurement to `remoteEnv` and `postCreateCommand` (also templates) and to `Config.Env` (the SUBSTITUTED containerEnv value), and to the read-back: `devcontainer exec --container-id` prints `LWF=[${localWorkspaceFolder}]`, `CWF=[${containerWorkspaceFolder}]`, `LENV=[/home/vscode]`.",
"behaviors": [
"bhv-container-metadata-label-authored"
]
},
{
"id": "src-obs-duplicate-keys",
"inventory": "observed",
Expand Down
19 changes: 17 additions & 2 deletions conformance/registry/sources/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,30 @@
"revision": "rev-spec-113500f4",
"locator": "Container creation and metadata",
"summary": "A created container's observable state (running status, labels, mounts, environment) reflects the resolved configuration.",
"behaviors": ["bhv-state-container-parity", "bhv-state-diff-parity"]
"behaviors": [
"bhv-state-container-parity",
"bhv-state-diff-parity"
]
},
{
"id": "src-spec-exec-command",
"inventory": "spec",
"revision": "rev-spec-113500f4",
"locator": "Command execution",
"summary": "A command is executed inside the container with its output streamed and its exit code propagated.",
"behaviors": ["bhv-exec-command-parity"]
"behaviors": [
"bhv-exec-command-parity"
]
},
{
"id": "src-spec-metadata-substitution-timing",
"inventory": "spec",
"revision": "rev-spec-113500f4",
"locator": "image-metadata.md#merge-logic",
"summary": "\"Variables in string values will be substituted at the time the value is applied.\" The recorded metadata therefore holds the template; substitution belongs to the point of application, not the point of recording (clu-image-metadata-variables-in-string-values-will-be-substituted-a-desc-31cd8289).",
"behaviors": [
"bhv-container-metadata-label-authored"
]
},
{
"id": "src-spec-readconfig-resolution",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"dockerVersion": "29.6.2-1",
"composeVersion": "2.40.3",
"imageDigests": {},
"normalizerVersion": "5",
"capturedAt": "2026-07-25T17:57:06Z"
"normalizerVersion": "6",
"capturedAt": "2026-07-26T00:15:30Z"
}
51 changes: 48 additions & 3 deletions crates/conformance/src/conservation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,29 @@ pub const POST_BRANCH_BEHAVIORS: &[(&str, &str)] = &[
fx-up-basic. This is a deacon EXTENSION, not a variant of any pre-migration claim: \
no existing behavior describes what either CLI labels a container with, because the \
retired `strip_intentional_labels` rule removed the whole `devcontainer.*` namespace \
before comparison. The three shared keys are NOT part of this behavior — \
`devcontainer.metadata` compares byte-equal, and `.local_folder` / `.config_file` \
differ only by each side's own temp workspace path and are normalized, not tolerated.",
before comparison. The three shared keys are NOT part of this behavior: \
`.local_folder` / `.config_file` differ only by each side's own temp workspace path \
and are normalized, not tolerated, and `devcontainer.metadata`'s CONTENT is its own \
claim — see `bhv-container-metadata-label-authored`. (This entry originally read \
'`devcontainer.metadata` compares byte-equal'. True on fx-up-basic, whose bare config \
contributes no picked property so both sides stamp `[]` — and false in general, which \
T115 measured. Corrected here rather than left standing, because a measurement on one \
fixture is not a claim about the field.)",
),
(
"bhv-container-metadata-label-authored",
"The `devcontainer.metadata` label records the AUTHORED configuration — variable \
templates intact — while the substituted values are applied to the container. Newly \
RECORDABLE for the same reason as the two entries around it: until \
`chan-container-state` became an observed channel no behavior described any label's \
CONTENT, and the one pre-migration source record that mentioned this label measured a \
fixture where both sides stamp `[]`. Not a variant of any pre-migration claim, and not \
a variant of `bhv-container-identity-labels` either: that behavior is about WHICH \
labels each CLI sets, this one about what the shared one CONTAINS — deacon set the \
label all along and still got its content wrong. Nor is it merely observed: \
image-metadata.md's Merge Logic states variables are substituted 'at the time the \
value is applied', so this is `spec: conformant` / `follow-spec` after fixing deacon, \
not a tolerated difference (T115).",
),
(
"bhv-container-keepalive-command",
Expand Down Expand Up @@ -665,6 +685,31 @@ pub const NORMALIZATION_RULES: &[NormalizationRule] = &[
),
known_non_compliant: None,
},
NormalizationRule {
name: "label_json_document",
scopes: &["channel:chan-container-state"],
action: RuleAction::Canonicalize,
removes: &[],
justification: Some(
"Parses the value of ONE enumerated label — `devcontainer.metadata`, whose \
value is itself a JSON document — and compares it structurally instead of as \
a byte string. This is `label_semantic` applied one level deeper: a label SET \
is a key/value mapping rather than an opaque string, and so is a label VALUE \
that is a JSON document. Measured against pinned oracle 0.87.0, both CLIs \
stamp the same fragments with different key insertion order and two extra \
spaces, e.g. deacon's \
`[{\"remoteUser\":\"dev\",\"containerUser\":\"dev\",…}]` versus the \
reference's `[ {\"containerEnv\":…,\"containerUser\":\"dev\",\"remoteUser\":\
\"dev\"} ]`. Removes nothing: every key and value is preserved and compared, \
only key order and insignificant whitespace stop mattering, array order is \
kept, a value that is not valid JSON is left verbatim so a malformed label \
still diverges, and a label key outside the enumerated set is untouched. The \
alternative — aligning deacon's insertion order with upstream's \
`pickConfigProperties` — would pin deacon to an implementation detail of the \
reference's serializer that carries no meaning and no stability guarantee.",
),
known_non_compliant: None,
},
NormalizationRule {
name: "label_semantic",
scopes: &["channel:chan-image"],
Expand Down
6 changes: 5 additions & 1 deletion crates/conformance/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ use serde_json::Value;
/// `portsAttributes`, where its key list was measured — it previously walked the whole
/// document, eliding an enumerated key NAME at any depth (including inside
/// `customizations`, arbitrary user data), which is the unbounded reach FR-029 forbids.
pub const NORMALIZER_VERSION: &str = "5";
/// T115 set it to `"6"`: `label_json_document` was added to the `chan-container-state`
/// chain, so the one label whose value is a JSON document (`devcontainer.metadata`)
/// compares as that document rather than as a byte string — key order and insignificant
/// whitespace stop mattering, nothing is removed.
pub const NORMALIZER_VERSION: &str = "6";

/// The `provenance.json` record — the FR-017 identity/environment elements (data-model
/// §7, contract snapshot-provenance.md). Thirteen fields: twelve identity/environment
Expand Down
96 changes: 94 additions & 2 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2739,14 +2739,46 @@ impl ConfigLoader {
/// ## Returns
///
/// Returns the merged and substituted configuration with substitution report.
#[instrument(skip_all, fields(path = %path.display(), merges = merge_config_paths.len()))]
pub async fn load_with_overrides_and_substitution(
path: &Path,
merge_config_paths: &[&Path],
secrets: Option<&crate::secrets::SecretsCollection>,
workspace_path: &Path,
resolve_devcontainer_id: bool,
) -> Result<(DevContainerConfig, crate::variable::SubstitutionReport)> {
let (_raw, substituted, report) = Self::load_with_overrides_and_substitution_raw(
path,
merge_config_paths,
secrets,
workspace_path,
resolve_devcontainer_id,
)
.await?;
Ok((substituted, report))
}

/// As [`ConfigLoader::load_with_overrides_and_substitution`], but also returns
/// the merged configuration **before** variable substitution.
///
/// Returns `(raw, substituted, report)`, mirroring the reference CLI's
/// `SubstitutedConfig { raw, config }`: some outputs describe the resolved
/// configuration (substituted) and some describe the authored one (raw). The
/// `devcontainer.metadata` container label is the second kind — it exists so a
/// later reader can recover the configuration, so it must carry the templates,
/// not this machine's absolute paths (measured against pinned oracle 0.87.0;
/// see `up::merged_config::build_container_metadata_label`).
#[instrument(skip_all, fields(path = %path.display(), merges = merge_config_paths.len()))]
pub async fn load_with_overrides_and_substitution_raw(
path: &Path,
merge_config_paths: &[&Path],
secrets: Option<&crate::secrets::SecretsCollection>,
workspace_path: &Path,
resolve_devcontainer_id: bool,
) -> Result<(
DevContainerConfig,
DevContainerConfig,
crate::variable::SubstitutionReport,
)> {
debug!(
"Loading configuration with merge fragments and substitution from {}",
path.display()
Expand Down Expand Up @@ -2810,7 +2842,7 @@ impl ConfigLoader {
merged.apply_variable_substitution(&substitution_context);

debug!("Configuration loading with overrides and substitution complete");
Ok((substituted_config, substitution_report))
Ok((merged, substituted_config, substitution_report))
}

/// Load configuration with variable substitution applied
Expand Down Expand Up @@ -4204,6 +4236,66 @@ mod tests {
);
}

/// T115: `load_with_overrides_and_substitution_raw` must return BOTH shapes of
/// the same configuration — the reference CLI's `SubstitutedConfig { raw,
/// config }`. Outputs describing the resolved container use `config`; the
/// `devcontainer.metadata` label, which a later reader recovers config from,
/// uses `raw`.
#[test]
fn test_load_raw_returns_pre_substitution_config() {
let temp_dir = TempDir::new().unwrap();
let dc_dir = temp_dir.path().join(".devcontainer");
std::fs::create_dir_all(&dc_dir).unwrap();
let config_path = dc_dir.join("devcontainer.json");
std::fs::write(
&config_path,
r#"{
"image": "debian:bookworm-slim",
"workspaceFolder": "/srv/app",
"containerEnv": { "APP_DIR": "${containerWorkspaceFolder}" },
"mounts": ["source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"]
}"#,
)
.unwrap();

let rt = tokio::runtime::Runtime::new().unwrap();
let (raw, substituted, _report) = rt
.block_on(ConfigLoader::load_with_overrides_and_substitution_raw(
&config_path,
&[],
None,
temp_dir.path(),
true,
))
.unwrap();

// raw: templates intact.
assert_eq!(
raw.container_env.get("APP_DIR").map(String::as_str),
Some("${containerWorkspaceFolder}")
);
assert_eq!(
raw.mounts.first().and_then(|m| m.as_str()),
Some("source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind")
);

// substituted: resolved, and identical to the non-`_raw` entry point.
assert_eq!(
substituted.container_env.get("APP_DIR").map(String::as_str),
Some("/srv/app")
);
let mount = substituted
.mounts
.first()
.and_then(|m| m.as_str())
.unwrap()
.to_string();
assert!(
!mount.contains("${localWorkspaceFolder}"),
"substituted mount should be resolved, got {mount}"
);
}

#[test]
fn test_substitution_covers_name_remote_env_users() {
// Per #107 — apply_variable_substitution must expand variables in
Expand Down
7 changes: 7 additions & 0 deletions crates/core/src/container_env_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,13 @@ impl ContainerEnvironmentProber {
feature_vars: HashMap::new(),
template_options: None,
resolve_devcontainer_id: true,
// No workspace in this context, so `${localWorkspaceFolder}`
// must stay literal rather than collapse to the empty string.
// In the ordinary flow config substitution already resolved it;
// the one path where the token can still arrive here is a config
// recovered from a container's `devcontainer.metadata` label,
// where the reference also leaves it literal.
resolve_local_workspace_folder: false,
};
let mut report = crate::variable::SubstitutionReport::new();
for (k, v_opt) in remote {
Expand Down
Loading
Loading