From 5dfe4c94cb8f1f73b7afc98e9f35603799a72374 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 14:49:22 +0000 Subject: [PATCH 1/7] fix: align official lifecycle hooks and feature metadata --- .../Commands/CloneCommand.swift | 108 +- .../Commands/ExecCommand.swift | 20 +- .../Commands/LifecycleRunner.swift | 462 +++++- .../Commands/PostAttachConfigLoader.swift | 169 +- .../Commands/RebuildCommand.swift | 287 +++- .../Commands/StartCommand.swift | 131 +- .../Commands/StopCommand.swift | 2 + .../ADevContainerLib/Commands/UpCommand.swift | 142 +- .../Config/ConfigAdmissions.swift | 4 + .../Config/ConfigResolver.swift | 8 + .../Config/DevContainerConfig.swift | 148 +- .../Features/DerivedImageTag.swift | 11 +- .../Features/DevContainerMetadataLabel.swift | 35 + .../Features/FeatureContributionMerge.swift | 13 + .../Features/FeatureDockerfileGenerator.swift | 18 + .../Features/FeaturesRunner.swift | 1 + .../Runtime/AppleContainerRuntime.swift | 8 +- .../Support/CommandSurface.swift | 47 +- .../Support/SuccessPresentation.swift | 27 + Sources/adevcontainer/AdevcontainerMain.swift | 13 +- .../adevcontainerTests/AllCommandTests.swift | 1384 ++++++++++++++++- Tests/adevcontainerTests/AllUnitTests.swift | 585 ++++++- .../CloneInVolumeTests.swift | 380 ++++- .../ConfigReaderTests.swift | 163 ++ .../RebuildCommandPhaseTests.swift | 396 ++++- .../RecoveryOrchestratorTests.swift | 3 + .../StartCommandRecoveryTests.swift | 74 + .../VSCodeCustomizationsCommandTests.swift | 200 ++- .../adevcontainerTests/VSCodeOpenTests.swift | 912 ++++++++++- .../align-official-lifecycle/proposal.md | 86 + .../changes/align-official-lifecycle/spec.md | 839 ++++++++++ .../changes/align-official-lifecycle/tasks.md | 341 ++++ wiki/architecture.md | 32 +- wiki/conventions/cli-runtime-boundary.md | 30 +- wiki/domain/devcontainer-apple-gaps.md | 18 +- wiki/index.md | 8 +- 36 files changed, 6741 insertions(+), 364 deletions(-) create mode 100644 specs/changes/align-official-lifecycle/proposal.md create mode 100644 specs/changes/align-official-lifecycle/spec.md create mode 100644 specs/changes/align-official-lifecycle/tasks.md diff --git a/Sources/ADevContainerLib/Commands/CloneCommand.swift b/Sources/ADevContainerLib/Commands/CloneCommand.swift index 01d68ed..8f2c741 100644 --- a/Sources/ADevContainerLib/Commands/CloneCommand.swift +++ b/Sources/ADevContainerLib/Commands/CloneCommand.swift @@ -274,6 +274,15 @@ public enum CloneCommand { try enforceHostRequirements(config: resolved.config, host: hostResources) + do { + try LifecycleRunner.runInitializeCommand( + config: resolved.config, + hostWorkspace: checkoutDir + ) + } catch { + throw BringUpRecovery.eligible(error) + } + // 5. Volume-mode identity (git URL + config rel path — not checkout path) let identity = ContainerIdentity.volumeModeIdentity( gitURL: url, @@ -343,9 +352,18 @@ public enum CloneCommand { knownOCIUser = featuresResult.baseImageUser } knownMetadataUsers = featuresResult.metadataUsers - } else if !options.skipPull { - StatusPrinter.status("Pulling image", item: effectiveConfig.image) - try? runtime.pullImage(effectiveConfig.image, platform: platform) + } else { + if !options.skipPull { + StatusPrinter.status("Pulling image", item: effectiveConfig.image) + try? runtime.pullImage(effectiveConfig.image, platform: platform) + } + let applied = try FeatureContributionMerge.applyFromImage( + imageRef: effectiveConfig.image, + to: effectiveConfig, + runtime: runtime + ) + effectiveConfig = applied.config + knownMetadataUsers = applied.users } // Expand `${devcontainerId}` with volume-mode create name (not bind-mode resolve name). @@ -497,9 +515,9 @@ public enum CloneCommand { runtime: runtime ) - // 11. Create-path lifecycle hooks (same matrix as up) + // 11. Create-path lifecycle hooks (same matrix as up), split at waitFor. do { - try LifecycleRunner.runCreatePath( + try LifecycleRunner.runCreatePathThroughWaitFor( containerId: id, config: effectiveConfig, runtime: runtime @@ -510,13 +528,6 @@ public enum CloneCommand { throw BringUpRecovery.eligible(error) } - // Settings apply after create-path hooks; not gated on --vscode. Soft-fail never deletes. - _ = VSCodeCustomizationsApply.applySettingsIfNeeded( - containerId: id, - config: effectiveConfig, - runtime: runtime - ) - let result = CloneResult( outcome: "success", containerId: id, @@ -526,30 +537,57 @@ public enum CloneCommand { gitUrl: identity.normalizedGitURL, workspaceVolume: identity.workspaceVolumeName ) - // Extensions apply when pending (not gated on `--vscode`) → open → postAttach. - _ = VSCodeCustomizationsApply.applyExtensionsIfNeeded( - containerId: id, - config: effectiveConfig, - runtime: runtime - ) - let openOutcome = VSCodeOpen.openIfRequested( - options.openVSCode, - target: VSCodeOpenTarget( - containerId: result.containerId, - image: effectiveConfig.image, - remoteWorkspaceFolder: result.remoteWorkspaceFolder, - containerName: result.containerName ?? identity.containerName, - remoteUser: result.remoteUser + + // Settings + Ready / JSON / open / postAttach at the waitFor point. + var readyError: Error? + do { + _ = VSCodeCustomizationsApply.applySettingsIfNeeded( + containerId: id, + config: effectiveConfig, + runtime: runtime ) - ) - try LifecycleRunner.applyPostAttachGate( - openOutcome: openOutcome, - containerId: id, - config: effectiveConfig, - runtime: runtime - ) - // Connection hints: entry point after success JSON (Ready → JSON → blank → connect). - StatusPrinter.status("Ready") + _ = VSCodeCustomizationsApply.applyExtensionsIfNeeded( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + let openOutcome = VSCodeOpen.openIfRequested( + options.openVSCode, + target: VSCodeOpenTarget( + containerId: result.containerId, + image: effectiveConfig.image, + remoteWorkspaceFolder: result.remoteWorkspaceFolder, + containerName: result.containerName ?? identity.containerName, + remoteUser: result.remoteUser + ) + ) + try LifecycleRunner.applyPostAttachGate( + openOutcome: openOutcome, + kind: .cliAttach, + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + StatusPrinter.status("Ready") + try SuccessPresentation.emitSuccessJSONIfRequested( + result.jsonString(), + jsonOutput: options.jsonOutput + ) + } catch { + readyError = error + } + + do { + try LifecycleRunner.runCreatePathAfterWaitFor( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + } catch { + try? runtime.deleteVolume(name: identity.workspaceVolumeName) + throw BringUpRecovery.eligible(error) + } + if let readyError { throw readyError } return result } diff --git a/Sources/ADevContainerLib/Commands/ExecCommand.swift b/Sources/ADevContainerLib/Commands/ExecCommand.swift index c9ad00a..6a0c048 100644 --- a/Sources/ADevContainerLib/Commands/ExecCommand.swift +++ b/Sources/ADevContainerLib/Commands/ExecCommand.swift @@ -32,8 +32,6 @@ public enum ExecCommand { let user = (labeledUser?.isEmpty == false) ? labeledUser : nil let labeledWorkdir = info.labels[ContainerIdentity.labelWorkspaceFolder] let workdir = (labeledWorkdir?.isEmpty == false) ? labeledWorkdir : nil - // containerEnv not stored on labels; Features PATH was baked at create. - let env: [String: String] = [:] guard info.isRunning else { throw CLIError( @@ -43,6 +41,24 @@ public enum ExecCommand { ) } + // Exec is not attach — probe (unless none) and merge; never run postAttach. + var env: [String: String] = [:] + if var loaded = try ConfigReader.read( + labels: info.labels, + containerId: info.id, + runtime: runtime, + mode: .bestEffort + ) { + if let user { loaded.remoteUser = user } + if let workdir { loaded.workspaceFolder = workdir } + try LifecycleRunner.applyUserEnvProbe( + containerId: info.id, + config: &loaded, + runtime: runtime + ) + env = loaded.containerEnv + } + let cmd = options.command.isEmpty ? ["bash"] : options.command let result = try runtime.exec( nameOrId: info.id, diff --git a/Sources/ADevContainerLib/Commands/LifecycleRunner.swift b/Sources/ADevContainerLib/Commands/LifecycleRunner.swift index c34deb9..233ebfb 100644 --- a/Sources/ADevContainerLib/Commands/LifecycleRunner.swift +++ b/Sources/ADevContainerLib/Commands/LifecycleRunner.swift @@ -9,8 +9,49 @@ public enum LifecycleRunner { case failKeepContainer } + /// Test seam for host `initializeCommand`. Production uses `FoundationProcessRunner`. + nonisolated(unsafe) public static var hostProcessRunnerOverride: (any ProcessRunning)? + + /// In-container dump of the remote user's shell environment (`/proc/self/environ`). + public static let userEnvProbeScript = "cat /proc/self/environ" + + /// Host-only `initializeCommand`. No-op when the command is absent. Skip + warn when + /// the caller reports no usable host workspace. Never delete-on-fail. + public static func runInitializeCommand( + config: ResolvedDevContainerConfig?, + hostWorkspace: String?, + fileManager: FileManager = .default + ) throws { + guard let command = config?.initializeCommand else { return } + guard let cwd = usableHostWorkspace(hostWorkspace, fileManager: fileManager) else { + StatusPrinter.warning("initializeCommand cannot run: no usable host workspace") + return + } + try runHostIfPresent( + property: "initializeCommand", + command: command, + currentDirectory: cwd + ) + } + + /// Directory the host command may use as cwd, or nil when the path is missing / not a dir / volume URI. + public static func usableHostWorkspace( + _ path: String?, + fileManager: FileManager = .default + ) -> String? { + guard let path else { return nil } + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.hasPrefix("volume://") else { return nil } + var isDir: ObjCBool = false + guard fileManager.fileExists(atPath: trimmed, isDirectory: &isDir), isDir.boolValue else { + return nil + } + return (trimmed as NSString).standardizingPath + } + /// Run a single lifecycle command if present. No-op when `command` is nil. - /// Object-form (`.parallel`) runs each named command sequentially in sorted name order. + /// Object-form (`.parallel`) runs named entries concurrently; the stage succeeds + /// only if every entry exits 0. Host `initializeCommand` uses this same policy. public static func runIfPresent( property: String, command: LifecycleCommand?, @@ -32,17 +73,89 @@ public enum LifecycleRunner { failurePolicy: failurePolicy ) case .parallel(let named): - for entry in named { - try runLeaf( - property: "\(property) (\(entry.name))", - command: entry.command, - containerId: containerId, - config: config, - runtime: runtime, - failurePolicy: failurePolicy - ) + try runParallel( + property: property, + named: named, + containerId: containerId, + config: config, + runtime: runtime, + failurePolicy: failurePolicy + ) + } + } + + /// Concurrent object-map stage. Leaves keep string/`argv` invocation unchanged. + private static func runParallel( + property: String, + named: [NamedLifecycleCommand], + containerId: String, + config: ResolvedDevContainerConfig, + runtime: AppleContainerRuntime, + failurePolicy: FailurePolicy + ) throws { + final class ParallelState: @unchecked Sendable { + let lock = NSLock() + var errors: [String: Error] = [:] + let property: String + let containerId: String + let config: ResolvedDevContainerConfig + let runtime: AppleContainerRuntime + + init( + property: String, + containerId: String, + config: ResolvedDevContainerConfig, + runtime: AppleContainerRuntime + ) { + self.property = property + self.containerId = containerId + self.config = config + self.runtime = runtime + } + + func run(_ entry: NamedLifecycleCommand) { + do { + try LifecycleRunner.runLeaf( + property: "\(property) (\(entry.name))", + command: entry.command, + containerId: containerId, + config: config, + runtime: runtime, + failurePolicy: .failKeepContainer + ) + } catch { + lock.lock() + if errors[entry.name] == nil { + errors[entry.name] = error + } + lock.unlock() + } + } + } + + let state = ParallelState( + property: property, + containerId: containerId, + config: config, + runtime: runtime + ) + let group = DispatchGroup() + for entry in named { + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + state.run(entry) + group.leave() } } + group.wait() + + if let failed = named.first(where: { state.errors[$0.name] != nil }), + let error = state.errors[failed.name] { + if failurePolicy == .deleteContainerThenFail { + try? runtime.delete(nameOrId: containerId, force: true) + } + throw error + } } private static func runLeaf( @@ -79,13 +192,143 @@ public enum LifecycleRunner { config: ResolvedDevContainerConfig, runtime: AppleContainerRuntime ) throws { - let stages: [(String, LifecycleCommand?, [LifecycleCommand])] = [ + try runCreatePathThroughWaitFor( + containerId: containerId, + config: config, + runtime: runtime + ) + try runCreatePathAfterWaitFor( + containerId: containerId, + config: config, + runtime: runtime + ) + } + + /// In-container create-path stages through `waitFor` inclusive. + /// Host `initializeCommand` is already done before this runs. + public static func runCreatePathThroughWaitFor( + containerId: String, + config: ResolvedDevContainerConfig, + runtime: AppleContainerRuntime + ) throws { + try runCreatePathStages( + containerId: containerId, + config: config, + runtime: runtime, + fromIndex: 0, + throughIndex: config.waitFor.createPathInclusiveIndex + ) + } + + /// Remaining create-path stages after `waitFor`. First-create `postStart` + /// still starts after `postCreate` even when Ready already fired. + public static func runCreatePathAfterWaitFor( + containerId: String, + config: ResolvedDevContainerConfig, + runtime: AppleContainerRuntime + ) throws { + try runCreatePathStages( + containerId: containerId, + config: config, + runtime: runtime, + fromIndex: config.waitFor.createPathInclusiveIndex + 1, + throughIndex: 3 + ) + } + + /// Probe the remote connection user's shell and merge into `config.containerEnv`. + /// `none` is a no-op. Config `containerEnv` wins on key overlap. Never deletes. + public static func applyUserEnvProbe( + containerId: String, + config: inout ResolvedDevContainerConfig, + runtime: AppleContainerRuntime + ) throws { + guard let flags = config.userEnvProbe.shellDashOptions else { return } + StatusPrinter.status("Probing user environment", item: config.userEnvProbe.rawValue) + let result: ProcessResult + do { + result = try runtime.exec( + nameOrId: containerId, + command: ["sh", flags, userEnvProbeScript], + user: config.connectionUser, + workdir: config.workspaceFolder, + env: [:], + streamOutput: false + ) + } catch let err as CLIError { + throw userEnvProbeError( + message: "userEnvProbe failed: \(err.message)", + hint: err.hint + ) + } + guard result.succeeded else { + throw userEnvProbeError( + message: "userEnvProbe failed with exit \(result.exitCode)" + + probeDetailSuffix(result), + hint: "Fix the remote user shell environment or set userEnvProbe to none" + ) + } + var merged = parseProbedEnviron(result.stdout) + for (key, value) in config.containerEnv { + merged[key] = value + } + config.containerEnv = merged + } + + /// Parse null-separated `/proc/self/environ` or newline-separated `KEY=VALUE` lines. + public static func parseProbedEnviron(_ data: Data) -> [String: String] { + let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + let records: [String] + if text.contains("\0") { + records = text.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) + } else { + records = text.split(whereSeparator: { $0 == "\n" || $0 == "\r" }).map(String.init) + } + var env: [String: String] = [:] + for record in records { + guard let eq = record.firstIndex(of: "=") else { continue } + let key = String(record[.. Bool { + waitFor == .postStartCommand + } + + private static func createPathStages( + config: ResolvedDevContainerConfig + ) -> [(String, LifecycleCommand?, [LifecycleCommand])] { + [ ("onCreateCommand", config.onCreateCommand, config.featureOnCreateCommands), ("updateContentCommand", config.updateContentCommand, config.featureUpdateContentCommands), ("postCreateCommand", config.postCreateCommand, config.featurePostCreateCommands), ("postStartCommand", config.postStartCommand, config.featurePostStartCommands) ] - for (property, primary, extras) in stages { + } + + private static func runCreatePathStages( + containerId: String, + config: ResolvedDevContainerConfig, + runtime: AppleContainerRuntime, + fromIndex: Int, + throughIndex: Int + ) throws { + let stages = createPathStages(config: config) + guard fromIndex <= throughIndex else { return } + let lower = max(fromIndex, 0) + let upper = min(throughIndex, stages.count - 1) + guard lower <= upper else { return } + var config = config + if stages[lower...upper].contains(where: { $0.1 != nil || !$0.2.isEmpty }) { + try applyUserEnvProbe(containerId: containerId, config: &config, runtime: runtime) + } + for index in lower...upper { + let (property, primary, extras) = stages[index] try runIfPresent( property: property, command: primary, @@ -94,10 +337,10 @@ public enum LifecycleRunner { runtime: runtime, failurePolicy: .deleteContainerThenFail ) - for (index, extra) in extras.enumerated() { + for (extraIndex, extra) in extras.enumerated() { let label = extras.count == 1 ? "\(property) (feature)" - : "\(property) (feature \(index + 1))" + : "\(property) (feature \(extraIndex + 1))" try runIfPresent( property: label, command: extra, @@ -116,6 +359,10 @@ public enum LifecycleRunner { config: ResolvedDevContainerConfig, runtime: AppleContainerRuntime ) throws { + var config = config + if config.postStartCommand != nil || !config.featurePostStartCommands.isEmpty { + try applyUserEnvProbe(containerId: containerId, config: &config, runtime: runtime) + } try runIfPresent( property: "postStartCommand", command: config.postStartCommand, @@ -145,13 +392,13 @@ public enum LifecycleRunner { } /// Emit one-time postAttach skip status when the property is set (config or feature) - /// and there is no CLI attach hook (`--vscode` absent). + /// and already-running `start` had no successful attach open. public static func emitPostAttachSkipIfNeeded(config: ResolvedDevContainerConfig) { guard hasPostAttach(config) else { return } StatusPrinter.status("postAttach skipped", item: "(no attach hook)") } - /// Skip when `--vscode` was set but open soft-failed/skipped and postAttach is present. + /// Skip when already-running `start --vscode` open soft-failed/skipped and postAttach is present. public static func emitPostAttachSkipOpenDidNotSucceedIfNeeded(config: ResolvedDevContainerConfig) { guard hasPostAttach(config) else { return } StatusPrinter.status("postAttach skipped", item: "(attach open did not succeed)") @@ -164,6 +411,10 @@ public enum LifecycleRunner { config: ResolvedDevContainerConfig, runtime: AppleContainerRuntime ) throws { + var config = config + if hasPostAttach(config) { + try applyUserEnvProbe(containerId: containerId, config: &config, runtime: runtime) + } try runIfPresent( property: "postAttachCommand", command: config.postAttachCommand, @@ -187,34 +438,173 @@ public enum LifecycleRunner { } } + /// Whether this invocation is itself a CLI attach (run postAttach) or an + /// already-running `start` (run only after successful `--vscode` open). + public enum PostAttachKind: Sendable { + /// `up` / `clone` / `rebuild` / real `start` — run after waitFor. + /// `--vscode` open success or soft-fail MUST NOT skip. + case cliAttach + /// Already-running `start` — run only after successful `--vscode` open. + case alreadyRunning + } + /// Outcome-aware postAttach gate after the open attempt (or no-op when not requested). /// /// Callers that apply vscode extensions MUST run extensions apply **before** this gate /// on open success. This gate never runs customizations apply and never uses /// `postAttachCommand` as an apply vehicle. /// + /// CLI-attach (`.cliAttach`): always run config then feature postAttach (`failKeepContainer`). + /// Already-running (`.alreadyRunning`): /// - `.notRequested` → skip status when any postAttach present /// - `.opened` → run config then feature postAttach (`failKeepContainer`) /// - soft-fail open → skip status explaining attach open did not succeed public static func applyPostAttachGate( openOutcome: VSCodeOpenOutcome, + kind: PostAttachKind, containerId: String, config: ResolvedDevContainerConfig, runtime: AppleContainerRuntime ) throws { - switch openOutcome { - case .notRequested: - emitPostAttachSkipIfNeeded(config: config) - case .opened: + switch kind { + case .cliAttach: try runPostAttach(containerId: containerId, config: config, runtime: runtime) - case .skippedMissingCode, .skippedEmptyFolder, .skippedMissingImage, .skippedMissingId, .launchFailed: - emitPostAttachSkipOpenDidNotSucceedIfNeeded(config: config) + case .alreadyRunning: + switch openOutcome { + case .notRequested: + emitPostAttachSkipIfNeeded(config: config) + case .opened: + try runPostAttach(containerId: containerId, config: config, runtime: runtime) + case .skippedMissingCode, .skippedEmptyFolder, .skippedMissingImage, .skippedMissingId, .launchFailed: + emitPostAttachSkipOpenDidNotSucceedIfNeeded(config: config) + } + } + } + + private static func hostProcessRunner() -> any ProcessRunning { + hostProcessRunnerOverride ?? FoundationProcessRunner() + } + + private static func runHostIfPresent( + property: String, + command: LifecycleCommand, + currentDirectory: String + ) throws { + switch command { + case .shell, .argv: + try runHostLeaf( + property: property, + command: command, + currentDirectory: currentDirectory + ) + case .parallel(let named): + try runHostParallel( + property: property, + named: named, + currentDirectory: currentDirectory + ) } } + private static func runHostParallel( + property: String, + named: [NamedLifecycleCommand], + currentDirectory: String + ) throws { + final class HostParallelState: @unchecked Sendable { + let lock = NSLock() + var errors: [String: Error] = [:] + let property: String + let currentDirectory: String + + init(property: String, currentDirectory: String) { + self.property = property + self.currentDirectory = currentDirectory + } + + func run(_ entry: NamedLifecycleCommand) { + do { + try LifecycleRunner.runHostLeaf( + property: "\(property) (\(entry.name))", + command: entry.command, + currentDirectory: currentDirectory + ) + } catch { + lock.lock() + if errors[entry.name] == nil { + errors[entry.name] = error + } + lock.unlock() + } + } + } + + let state = HostParallelState(property: property, currentDirectory: currentDirectory) + let group = DispatchGroup() + for entry in named { + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + state.run(entry) + group.leave() + } + } + group.wait() + + if let failed = named.first(where: { state.errors[$0.name] != nil }), + let error = state.errors[failed.name] { + throw error + } + } + + private static func runHostLeaf( + property: String, + command: LifecycleCommand, + currentDirectory: String + ) throws { + StatusPrinter.status("Running", item: property) + let invocation = hostInvocation(command) + let runner = hostProcessRunner() + let result: ProcessResult + if let streaming = runner as? any StreamTeeingProcessRunning { + result = try streaming.run( + executable: invocation.executable, + arguments: invocation.arguments, + environment: nil, + currentDirectory: currentDirectory, + stdinData: nil, + streamStderr: true, + teeStdoutToStderr: true + ) + } else { + result = try runner.run( + executable: invocation.executable, + arguments: invocation.arguments, + environment: nil, + currentDirectory: currentDirectory + ) + } + guard result.succeeded else { + throw lifecycleError(property: property, execResult: result, isHost: true) + } + } + + private static func hostInvocation( + _ command: LifecycleCommand + ) -> (executable: String, arguments: [String]) { + let argv = command.execArguments + guard let first = argv.first else { + return ("/bin/sh", ["-c", ":"]) + } + if first.contains("/") { + return (first, Array(argv.dropFirst())) + } + return ("/usr/bin/env", argv) + } + private static func lifecycleError( property: String, - execResult: ProcessResult + execResult: ProcessResult, + isHost: Bool = false ) -> CLIError { let detail = [ execResult.stderrString.trimmingCharacters(in: .whitespacesAndNewlines), @@ -236,12 +626,36 @@ public enum LifecycleRunner { code = CLIErrorCode.lifecycleFailed } + let hint: String + if isHost || baseProperty == "initializeCommand" { + hint = "Fix the \(baseProperty) and re-run" + } else { + hint = "Fix the \(baseProperty) or exec into the container to debug" + } + return CLIError( code: code, property: property, message: "\(property) failed with exit \(execResult.exitCode)" + (detail.isEmpty ? "" : ": \(detail)"), - hint: "Fix the \(baseProperty) or exec into the container to debug" + hint: hint + ) + } + + private static func probeDetailSuffix(_ result: ProcessResult) -> String { + let detail = [ + result.stderrString.trimmingCharacters(in: .whitespacesAndNewlines), + result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + ].filter { !$0.isEmpty }.joined(separator: " | ") + return detail.isEmpty ? "" : ": \(detail)" + } + + private static func userEnvProbeError(message: String, hint: String?) -> CLIError { + CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "userEnvProbe", + message: message, + hint: hint ?? "Fix the remote user shell environment or set userEnvProbe to none" ) } } diff --git a/Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift b/Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift index bf8018e..ede9758 100644 --- a/Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift +++ b/Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift @@ -5,7 +5,8 @@ import Foundation /// /// Bind-mode: re-resolve from host `local_folder` + `config_file` labels. /// Volume-mode: `cat` the stamped config path inside the container workspace. -/// Feature postAttach: merge from image `devcontainer.metadata` when inspect labels expose it. +/// Feature postStart / postAttach: union container + image `devcontainer.metadata`, then remelt +/// from admitted feature packages on disk when metadata has no resume hooks. /// /// Resolved config retains `vscodeExtensions` / `vscodeSettingsJSON` from the config file /// (ConfigResolver); callers use the same model for settings repair and open-gated extensions. @@ -37,46 +38,170 @@ public enum PostAttachConfigLoader { mode: .bestEffort ) - guard var resolved = config else { return nil } + if var resolved = config { + // Prefer stamped workspace folder / user for exec workdir and remoteUser. + if !stampedFolder.isEmpty { + resolved.workspaceFolder = stampedFolder + } + if let stampedUser, !stampedUser.isEmpty { + resolved.remoteUser = stampedUser + } - // Prefer stamped workspace folder / user for exec workdir and remoteUser. - if !stampedFolder.isEmpty { - resolved.workspaceFolder = stampedFolder - } - if let stampedUser, !stampedUser.isEmpty { - resolved.remoteUser = stampedUser + // Feature-contributed postStart / postAttach may live on image metadata (not re-run Features). + mergeFeaturePostAttach( + into: &resolved, + labels: labels, + imageRef: imageRef, + runtime: runtime + ) + return resolved } - // Feature-contributed postAttach may live on image metadata (not re-run Features). + // Config unreadable: still remelt container+image metadata into a feature-only stub + // so resume postStart / CLI-attach postAttach survive. No vscode apply, no initialize. + return remeltedMetadataOnlyConfig( + labels: labels, + imageRef: imageRef, + runtime: runtime, + workspaceFolder: stampedFolder, + remoteUser: stampedUser + ) + } + + /// Stub config from container+image metadata when the stamped config file cannot be read. + static func remeltedMetadataOnlyConfig( + labels: [String: String], + imageRef: String?, + runtime: AppleContainerRuntime, + workspaceFolder: String, + remoteUser: String? + ) -> ResolvedDevContainerConfig? { + var stub = ResolvedDevContainerConfig( + image: imageRef ?? "", + remoteUser: remoteUser, + workspaceFolder: workspaceFolder.isEmpty ? "/" : workspaceFolder, + userEnvProbe: .none + ) mergeFeaturePostAttach( - into: &resolved, + into: &stub, labels: labels, imageRef: imageRef, runtime: runtime ) - - return resolved + if stub.featurePostStartCommands.isEmpty && stub.featurePostAttachCommands.isEmpty { + return nil + } + return stub } - /// Merge feature postAttach from image/container `devcontainer.metadata` when present. - /// Used by bare `start` (via `load`) and by `up` reuse/restart (no Features re-run). + /// Merge feature postStart and postAttach from image/container `devcontainer.metadata` + /// when present. Used by bare `start` (via `load`) and by `up` reuse/restart (no Features re-run). + /// + /// Container and image labels are **unioned**: a container that inherited base-image + /// metadata (e.g. `remoteUser` only) must not hide feature postStart on the derived image. + /// When metadata still has no resume hooks, remelt from admitted features on disk + /// (local package or feature cache) — the equivalent source for containers created + /// before Features baked `devcontainer.metadata`. public static func mergeFeaturePostAttach( into config: inout ResolvedDevContainerConfig, labels: [String: String] = [:], imageRef: String?, - runtime: AppleContainerRuntime + runtime: AppleContainerRuntime, + workspacePath: String? = nil ) { - var labelSource = labels - if labelSource[DevContainerMetadataLabel.labelKey] == nil, - let imageRef, + let fromContainer = DevContainerMetadataLabel.parseContributions(from: labels) + var fromImage = FeatureContributions.empty + if let imageRef, !imageRef.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let imageLabels = try? runtime.imageLabels(ref: imageRef) { - labelSource = imageLabels + fromImage = DevContainerMetadataLabel.parseContributions(from: imageLabels) } - let meta = DevContainerMetadataLabel.parseContributions(from: labelSource) - if !meta.postAttachCommands.isEmpty { - config.featurePostAttachCommands = meta.postAttachCommands + let remeltedStart = uniqueAppend(fromContainer.postStartCommands, fromImage.postStartCommands) + let remeltedAttach = uniqueAppend(fromContainer.postAttachCommands, fromImage.postAttachCommands) + // Union with apply-time hooks so finish remelt cannot replace-away base-image fragments. + config.featurePostStartCommands = uniqueAppend(config.featurePostStartCommands, remeltedStart) + config.featurePostAttachCommands = uniqueAppend(config.featurePostAttachCommands, remeltedAttach) + remeltFromAdmittedFeatures( + into: &config, + workspacePath: workspacePath ?? labels[ContainerIdentity.labelLocalFolder] + ) + } + + /// Best-effort remelt from resolved `features` without a Features rebuild or network fetch. + /// Local path refs are read from the host workspace; OCI refs from the feature cache. + static func remeltFromAdmittedFeatures( + into config: inout ResolvedDevContainerConfig, + workspacePath: String?, + cacheRoot: String = FeatureCache.defaultRoot(), + fileManager: FileManager = .default + ) { + if !config.featurePostStartCommands.isEmpty && !config.featurePostAttachCommands.isEmpty { + return + } + guard !config.features.isEmpty else { return } + + let hostWorkspace = usableHostWorkspace(workspacePath) + var orderedInput: [FeatureOrder.OrderedFeature] = [] + for feature in config.features { + guard let metaPath = metadataPath( + for: feature.reference, + workspacePath: hostWorkspace, + cacheRoot: cacheRoot, + fileManager: fileManager + ) else { continue } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: metaPath)), + let metadata = try? FeatureMetadata.parse(data: data, featureRef: feature.reference) + else { continue } + orderedInput.append(FeatureOrder.OrderedFeature(admitted: feature, metadata: metadata)) + } + guard !orderedInput.isEmpty else { return } + let ordered = (try? FeatureOrder.resolve(orderedInput)) ?? orderedInput + guard let contrib = try? FeatureContributionMerge.collect(from: ordered) else { return } + if config.featurePostStartCommands.isEmpty && !contrib.postStartCommands.isEmpty { + config.featurePostStartCommands = contrib.postStartCommands + } + if config.featurePostAttachCommands.isEmpty && !contrib.postAttachCommands.isEmpty { + config.featurePostAttachCommands = contrib.postAttachCommands + } + } + + private static func uniqueAppend( + _ first: [LifecycleCommand], + _ second: [LifecycleCommand] + ) -> [LifecycleCommand] { + var out = first + for cmd in second where !out.contains(cmd) { + out.append(cmd) + } + return out + } + + private static func usableHostWorkspace(_ path: String?) -> String? { + guard let path else { return nil } + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.hasPrefix("volume://") else { return nil } + return trimmed + } + + private static func metadataPath( + for reference: String, + workspacePath: String?, + cacheRoot: String, + fileManager: FileManager + ) -> String? { + if FeatureRef.isLocalPath(reference) { + guard let workspacePath, + let source = try? LocalFeatureLoader.resolveSourcePath( + reference: reference, + workspacePath: workspacePath + ) + else { return nil } + let path = (source as NSString).appendingPathComponent("devcontainer-feature.json") + return fileManager.fileExists(atPath: path) ? path : nil } + let dir = FeatureCache.directory(for: reference, cacheRoot: cacheRoot) + let path = (dir as NSString).appendingPathComponent("devcontainer-feature.json") + return fileManager.fileExists(atPath: path) ? path : nil } } diff --git a/Sources/ADevContainerLib/Commands/RebuildCommand.swift b/Sources/ADevContainerLib/Commands/RebuildCommand.swift index 3ef817d..e656986 100644 --- a/Sources/ADevContainerLib/Commands/RebuildCommand.swift +++ b/Sources/ADevContainerLib/Commands/RebuildCommand.swift @@ -5,7 +5,9 @@ import Foundation /// volume). Two phases split at the delete of the old container: /// /// - Phase A (non-destructive gate): selection → stamps → (volume, stopped) bare runtime -/// start → strict config read → hostRequirements preflight → Features gate +/// start → strict config read → hostRequirements preflight → host initialize +/// (volume-mode with no usable host workspace stages guest `.devcontainer/` / +/// root `.devcontainer.json` onto a temp cwd) → Features gate /// (rosetta consent, pull/skip-pull, derived-tag reuse). Any failure here fails /// `rebuild` with the old container untouched. /// - Phase B (destructive create path): container-only delete of the old container → @@ -192,6 +194,16 @@ public enum RebuildCommand { // hostRequirements preflight (same gate as up/clone; before pull/build). try enforceHostRequirements(config: resolvedConfig, host: hostResources) + try runHostInitialize( + config: resolvedConfig, + labels: labels, + isVolumeMode: isVolumeMode, + containerId: selected.id, + volumeRead: volumeRead, + runtime: runtime, + fileManager: fileManager + ) + var effectiveConfig = resolvedConfig let platform = ContainerPlatform.defaultLinuxPlatform @@ -252,9 +264,18 @@ public enum RebuildCommand { knownOCIUser = featuresResult.baseImageUser } knownMetadataUsers = featuresResult.metadataUsers - } else if !options.skipPull { - StatusPrinter.status("Pulling image", item: effectiveConfig.image) - try? runtime.pullImage(effectiveConfig.image, platform: platform) + } else { + if !options.skipPull { + StatusPrinter.status("Pulling image", item: effectiveConfig.image) + try? runtime.pullImage(effectiveConfig.image, platform: platform) + } + let applied = try FeatureContributionMerge.applyFromImage( + imageRef: effectiveConfig.image, + to: effectiveConfig, + runtime: runtime + ) + effectiveConfig = applied.config + knownMetadataUsers = applied.users } // Expand `${devcontainerId}` with the reused create name before hash / volume ensure. @@ -571,19 +592,13 @@ public enum RebuildCommand { ) } - // Create-path hooks (delete-on-fail of the new container). - do { - try LifecycleRunner.runCreatePath( - containerId: id, - config: effectiveConfig, - runtime: runtime - ) - } catch { - // Delete-on-fail must run exactly once. runCreatePath already deletes the - // new container when a create-path hook exits non-zero; only an exec-level - // failure (the hook could not run at all) leaves it in place. Deleting an - // already-removed container a second time would stream a spurious runtime - // notFound error to stderr before the warning below. + // Create-path hooks split at waitFor (delete-on-fail of the new container). + // Delete-on-fail must run exactly once. runCreatePath already deletes the + // new container when a create-path hook exits non-zero; only an exec-level + // failure (the hook could not run at all) leaves it in place. Deleting an + // already-removed container a second time would stream a spurious runtime + // notFound error to stderr before the warning below. + func handleCreatePathFailure(_ error: Error) throws -> RebuildResult { if allowRecovery, let recovery = recoveryContext { StatusPrinter.status("Create-path hook failed; entering recovery") return try RecoveryOrchestrator.recover( @@ -639,15 +654,26 @@ public enum RebuildCommand { throw error } - // Settings apply after create-path hooks; not gated on --vscode. - _ = VSCodeCustomizationsApply.applySettingsIfNeeded( - containerId: id, - config: effectiveConfig, - runtime: runtime - ) + do { + try LifecycleRunner.runCreatePathThroughWaitFor( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + } catch { + return try handleCreatePathFailure(error) + } - let result: RebuildResult + // Settings + Ready / JSON / open / postAttach at the waitFor point. + // postAttach failure must not start recovery. + var readyError: Error? + var result: RebuildResult? do { + _ = VSCodeCustomizationsApply.applySettingsIfNeeded( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) result = try finish( options: options, id: id, @@ -658,10 +684,29 @@ public enum RebuildCommand { runtime: runtime ) } catch { + readyError = error + } + + do { + try LifecycleRunner.runCreatePathAfterWaitFor( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + } catch { + return try handleCreatePathFailure(error) + } + if let readyError { // Open/postAttach failures are terminal non-recovery outcomes. Do not leak the // prepared session after the delete boundary. if let recoveryContext { try? recoveryContext.session.cleanup() } - throw error + throw readyError + } + guard let result else { + throw CLIError( + code: CLIErrorCode.internalError, + message: "rebuild finished waitFor without a result" + ) } if let recovery = recoveryContext { if recoveryEndpointID != nil { @@ -777,8 +822,10 @@ public enum RebuildCommand { // MARK: - Finish (extensions → open → postAttach gate), up parity - /// Extensions apply (not flag-gated) → open (optional) → postAttach gate → Ready. - /// postAttach is never before open when `--vscode`. Extensions never fold into postAttachCommand. + /// Extensions apply (not flag-gated) → open (optional) → CLI-attach postAttach → Ready + /// (and `--json` success JSON). Called at the `waitFor` point, not after later hooks. + /// postAttach is never before open when `--vscode`. Open soft-fail does not skip postAttach. + /// Extensions never fold into postAttachCommand. private static func finish( options: RebuildOptions, id: String, @@ -817,16 +864,22 @@ public enum RebuildCommand { var postAttachConfig = config PostAttachConfigLoader.mergeFeaturePostAttach( into: &postAttachConfig, + labels: imagesLabels, imageRef: config.image.isEmpty ? nil : config.image, runtime: runtime ) try LifecycleRunner.applyPostAttachGate( openOutcome: openOutcome, + kind: .cliAttach, containerId: id, config: postAttachConfig, runtime: runtime ) StatusPrinter.status("Ready") + try SuccessPresentation.emitSuccessJSONIfRequested( + result.jsonString(), + jsonOutput: options.jsonOutput + ) return result } @@ -849,6 +902,186 @@ public enum RebuildCommand { ) } + /// Host `initializeCommand` before old-container delete / new create. + /// Usable stamped / retained host path wins. Volume-mode with no host workspace + /// stages the live guest config directory/files onto a temp root (not a full checkout). + private static func runHostInitialize( + config: ResolvedDevContainerConfig, + labels: [String: String], + isVolumeMode: Bool, + containerId: String, + volumeRead: ResolvedVolumeConfigRead?, + runtime: AppleContainerRuntime, + fileManager: FileManager + ) throws { + guard config.initializeCommand != nil else { return } + let stamped = stampedLocalFolder(labels) + if let durable = LifecycleRunner.usableHostWorkspace(stamped, fileManager: fileManager) { + try LifecycleRunner.runInitializeCommand( + config: config, + hostWorkspace: durable, + fileManager: fileManager + ) + return + } + if isVolumeMode, let volumeRead { + let staged = try stageGuestInitializeWorkspace( + containerId: containerId, + raw: volumeRead.raw, + runtime: runtime, + fileManager: fileManager + ) + defer { removeStagedInitializeRoot(staged, fileManager: fileManager) } + try LifecycleRunner.runInitializeCommand( + config: config, + hostWorkspace: staged.path, + fileManager: fileManager + ) + return + } + try LifecycleRunner.runInitializeCommand( + config: config, + hostWorkspace: stamped, + fileManager: fileManager + ) + } + + /// Place the current guest `.devcontainer/` (when present) and/or root + /// `.devcontainer.json` onto a host temp workspace root. Not a full checkout. + private static func stageGuestInitializeWorkspace( + containerId: String, + raw: RawVolumeConfig, + runtime: AppleContainerRuntime, + fileManager: FileManager + ) throws -> URL { + let tempDir = fileManager.temporaryDirectory + .appendingPathComponent("adev-init-\(UUID().uuidString)", isDirectory: true) + do { + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + } catch { + throw CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "initializeCommand", + message: "Failed to stage guest config for initializeCommand", + hint: "Ensure the invoking user can write its temporary directory" + ) + } + + do { + let guestDir = (raw.workspaceFolder as NSString).appendingPathComponent(".devcontainer") + let dirCheck: ProcessResult + do { + dirCheck = try runtime.exec( + nameOrId: containerId, + command: ["test", "-d", guestDir] + ) + } catch { + throw CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "initializeCommand", + message: "Failed to inspect guest .devcontainer for initializeCommand", + hint: "The container must be running so rebuild can stage the guest config directory" + ) + } + if dirCheck.succeeded { + try extractGuestDevcontainerArchive( + containerId: containerId, + workspaceFolder: raw.workspaceFolder, + dest: tempDir, + runtime: runtime, + fileManager: fileManager + ) + } + + let configName = (raw.pathInContainer as NSString).lastPathComponent + let configParent = (raw.pathInContainer as NSString).deletingLastPathComponent + if configName == ConfigDiscovery.rootRelativePath, + (configParent as NSString).standardizingPath + == (raw.workspaceFolder as NSString).standardizingPath + { + try raw.bytes.write( + to: tempDir.appendingPathComponent(ConfigDiscovery.rootRelativePath) + ) + } + return tempDir + } catch { + try? fileManager.removeItem(at: tempDir) + throw error + } + } + + /// `exec tar cf -` of guest `.devcontainer/` then host `tar xf`. Not `container cp`. + private static func extractGuestDevcontainerArchive( + containerId: String, + workspaceFolder: String, + dest: URL, + runtime: AppleContainerRuntime, + fileManager: FileManager + ) throws { + let archive: ProcessResult + do { + archive = try runtime.exec( + nameOrId: containerId, + command: ["tar", "cf", "-", "-C", workspaceFolder, ".devcontainer"] + ) + } catch { + throw CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "initializeCommand", + message: "Failed to archive guest .devcontainer for initializeCommand", + hint: "The container must be running and tar must be available in the image" + ) + } + guard archive.succeeded, !archive.stdout.isEmpty else { + throw CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "initializeCommand", + message: "Failed to archive guest .devcontainer for initializeCommand", + hint: "The container must be running and tar must be available in the image" + ) + } + + let tarURL = fileManager.temporaryDirectory + .appendingPathComponent("adev-init-archive-\(UUID().uuidString).tar") + do { + try archive.stdout.write(to: tarURL) + } catch { + throw CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "initializeCommand", + message: "Failed to stage guest .devcontainer archive for initializeCommand", + hint: "Ensure the invoking user can write its temporary directory" + ) + } + defer { try? fileManager.removeItem(at: tarURL) } + + let extract = try FoundationProcessRunner().run( + executable: "/usr/bin/tar", + arguments: ["xf", tarURL.path, "-C", dest.path], + environment: nil, + currentDirectory: nil + ) + guard extract.succeeded else { + throw CLIError( + code: CLIErrorCode.lifecycleFailed, + property: "initializeCommand", + message: "Failed to extract guest .devcontainer for initializeCommand", + hint: "Ensure /usr/bin/tar is available" + ) + } + } + + private static func removeStagedInitializeRoot(_ url: URL, fileManager: FileManager) { + guard fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.removeItem(at: url) + } catch { + StatusPrinter.warning( + "Failed to remove temp directory \(url.path): \(error.localizedDescription)" + ) + } + } + private static func stampedLocalFolder(_ labels: [String: String]) -> String { labels[ContainerIdentity.labelLocalFolder]? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/Sources/ADevContainerLib/Commands/StartCommand.swift b/Sources/ADevContainerLib/Commands/StartCommand.swift index 38f0c34..b60ba41 100644 --- a/Sources/ADevContainerLib/Commands/StartCommand.swift +++ b/Sources/ADevContainerLib/Commands/StartCommand.swift @@ -20,7 +20,11 @@ public enum StartCommand { nonisolated(unsafe) public static var rebuildOverride: ((RebuildOptions) throws -> RebuildResult)? /// Start a stopped managed container. - /// Create-path / postStart hooks stay on `up`/`clone`; postAttach is gated after optional open. + /// Host initialize runs on a real start when a host workspace exists; then start; + /// then config + remelted feature postStart. Already-running skips those hooks. + /// Real start runs postAttach as CLI attach (not `--vscode`-gated). Already-running + /// runs postAttach only after successful `--vscode` open. Never applies settings or extensions. + /// Hook progress and `==> Ready` use StatusPrinter (stderr); `--json` keeps stdout clean. public static func run( options: StartOptions, runtime: AppleContainerRuntime, @@ -35,8 +39,15 @@ public enum StartCommand { ) if info.isRunning { - print("Container \(info.id) already running") - try openAndPostAttach(options: options, nameOrId: info.id, runtime: runtime, picker: picker) + StatusPrinter.status("Container already running", item: info.id) + try openAndPostAttach( + options: options, + nameOrId: info.id, + runtime: runtime, + picker: picker, + kind: .alreadyRunning + ) + StatusPrinter.status("Ready") SuccessPresentation.emitConnectionHintsIfNeeded( openVSCode: options.openVSCode, nameOrId: info.name @@ -44,6 +55,15 @@ public enum StartCommand { return } + let hostWorkspace = info.labels[ContainerIdentity.labelLocalFolder] + // Initialize is host/config-only — do not remelt metadata before start + // (start-failure recovery must not inspect the image). + var hooksConfig = loadInitializeConfig(info: info, runtime: runtime) + try LifecycleRunner.runInitializeCommand( + config: hooksConfig, + hostWorkspace: hostWorkspace + ) + StatusPrinter.status("Starting container", item: info.id) do { try runtime.start(nameOrId: info.id) @@ -59,9 +79,66 @@ public enum StartCommand { ) return } - // Bare start: no create-path / postStart. No settings or extensions apply. postAttach via open gate. - print("Started \(info.id)") - try openAndPostAttach(options: options, nameOrId: info.id, runtime: runtime, picker: picker) + // Remelt after start for postStart/postAttach. Never run initialize here: + // volume config becoming readable must not violate initialize-before-start. + if hooksConfig == nil { + hooksConfig = loadHooksConfig(info: info, runtime: runtime) + } else if var loaded = hooksConfig { + // Remelt again after start so image-only metadata and a fresh inspect + // (list may omit image ref / inherited labels) are visible. + let live = (try? runtime.inspect(nameOrId: info.id)) ?? info + PostAttachConfigLoader.mergeFeaturePostAttach( + into: &loaded, + labels: live.labels, + imageRef: live.image ?? info.image, + runtime: runtime, + workspacePath: hostWorkspace + ) + hooksConfig = loaded + } + // Resume: create-path waitFor is already satisfied — do not re-exec onCreate / + // updateContent / postCreate. Config then remelted feature postStart run via + // LifecycleRunner (userEnvProbe merge applies). If waitFor is postStartCommand, + // hold open / postAttach until this start's postStart finishes. + // Recovery that delegated to rebuild already returned. Never apply settings/extensions. + if let config = hooksConfig { + if LifecycleRunner.resumeShouldWaitForPostStart(config.waitFor) { + try LifecycleRunner.runRestartPostStart( + containerId: info.id, + config: config, + runtime: runtime + ) + try openAndPostAttach( + options: options, + nameOrId: info.id, + runtime: runtime, + picker: picker, + kind: .cliAttach + ) + } else { + try openAndPostAttach( + options: options, + nameOrId: info.id, + runtime: runtime, + picker: picker, + kind: .cliAttach + ) + try LifecycleRunner.runRestartPostStart( + containerId: info.id, + config: config, + runtime: runtime + ) + } + } else { + try openAndPostAttach( + options: options, + nameOrId: info.id, + runtime: runtime, + picker: picker, + kind: .cliAttach + ) + } + StatusPrinter.status("Ready") SuccessPresentation.emitConnectionHintsIfNeeded( openVSCode: options.openVSCode, nameOrId: info.name @@ -154,13 +231,50 @@ public enum StartCommand { ) } + /// Host initialize only: stamped config without metadata remelt (no image inspect). + private static func loadInitializeConfig( + info: ContainerInfo, + runtime: AppleContainerRuntime + ) -> ResolvedDevContainerConfig? { + do { + return try ConfigReader.read( + labels: info.labels, + containerId: info.id, + runtime: runtime, + mode: .bestEffort + ) + } catch { + return nil + } + } + + /// Hooks/open/postAttach config from stamped labels. Never used to apply settings/extensions. + /// Remelts container+image metadata even when the stamped config is unreadable. + private static func loadHooksConfig( + info: ContainerInfo, + runtime: AppleContainerRuntime + ) -> ResolvedDevContainerConfig? { + do { + return try PostAttachConfigLoader.load( + labels: info.labels, + containerId: info.id, + imageRef: info.image, + runtime: runtime + ) + } catch { + return nil + } + } + /// Open (optional) then postAttach gate. Loads config from stamped labels for postAttach only. - /// `start` never applies settings or extensions. + /// `start` never applies settings or extensions. Real start is CLI attach; already-running + /// runs postAttach only after successful `--vscode` open. private static func openAndPostAttach( options: StartOptions, nameOrId: String, runtime: AppleContainerRuntime, - picker: InteractivePicker + picker: InteractivePicker, + kind: LifecycleRunner.PostAttachKind ) throws { // id / image / folder / labels from inspect (start has no UpResult). let payload: InspectPayload? @@ -214,6 +328,7 @@ public enum StartCommand { if let config { try LifecycleRunner.applyPostAttachGate( openOutcome: openOutcome, + kind: kind, containerId: payload.containerId, config: config, runtime: runtime diff --git a/Sources/ADevContainerLib/Commands/StopCommand.swift b/Sources/ADevContainerLib/Commands/StopCommand.swift index d5e6861..6b88820 100644 --- a/Sources/ADevContainerLib/Commands/StopCommand.swift +++ b/Sources/ADevContainerLib/Commands/StopCommand.swift @@ -17,6 +17,8 @@ public enum StopCommand { print("Container \(info.id) already stopped") return } + // Explicit `stop` always stops. `shutdownAction` `none` / omitted / `stopContainer` + // do not change this command; last-window-close auto-stop is out of scope. StatusPrinter.status("Stopping container", item: info.id) try runtime.stop(nameOrId: info.id) print("Stopped \(info.id)") diff --git a/Sources/ADevContainerLib/Commands/UpCommand.swift b/Sources/ADevContainerLib/Commands/UpCommand.swift index afb6c01..6024873 100644 --- a/Sources/ADevContainerLib/Commands/UpCommand.swift +++ b/Sources/ADevContainerLib/Commands/UpCommand.swift @@ -136,6 +136,15 @@ public enum UpCommand { // hostRequirements: fail up on shortfall/unreadable host; warn gpu; limits applied on create. try enforceHostRequirements(config: resolved.config, host: hostResources) + do { + try LifecycleRunner.runInitializeCommand( + config: resolved.config, + hostWorkspace: resolved.workspacePath + ) + } catch { + throw BringUpRecovery.eligible(error) + } + var existing = try runtime.findByName(resolved.containerName) // A stopped existing container may have failed during start, and a running one may @@ -193,25 +202,48 @@ public enum UpCommand { resetExistingName: existing.name ) } - let reuseConfig = configForReuse(resolved.config, labels: existing.labels) - do { - try LifecycleRunner.runRestartPostStart( + var reuseConfig = configForReuse(resolved.config, labels: existing.labels) + PostAttachConfigLoader.mergeFeaturePostAttach( + into: &reuseConfig, + labels: existing.labels, + imageRef: existing.image, + runtime: runtime + ) + if LifecycleRunner.resumeShouldWaitForPostStart(reuseConfig.waitFor) { + do { + try LifecycleRunner.runRestartPostStart( + containerId: existing.id, + config: reuseConfig, + runtime: runtime + ) + } catch { + throw BringUpRecovery.eligible( + error, + resetExistingName: existing.name + ) + } + _ = VSCodeCustomizationsApply.applySettingsIfNeeded( containerId: existing.id, config: reuseConfig, runtime: runtime ) - } catch { - throw BringUpRecovery.eligible( - error, - resetExistingName: existing.name + return try finish( + options: options, + id: existing.id, + name: existing.name, + config: reuseConfig, + image: existing.image ?? resolved.config.image, + runtime: runtime ) } + // Create-path waitFor is already satisfied on resume. Ready/open/postAttach + // fire now; this invocation's postStart still runs afterward. _ = VSCodeCustomizationsApply.applySettingsIfNeeded( containerId: existing.id, config: reuseConfig, runtime: runtime ) - return try finish( + let result = try finish( options: options, id: existing.id, name: existing.name, @@ -219,6 +251,19 @@ public enum UpCommand { image: existing.image ?? resolved.config.image, runtime: runtime ) + do { + try LifecycleRunner.runRestartPostStart( + containerId: existing.id, + config: reuseConfig, + runtime: runtime + ) + } catch { + throw BringUpRecovery.eligible( + error, + resetExistingName: existing.name + ) + } + return result } } @@ -278,9 +323,18 @@ public enum UpCommand { knownOCIUser = featuresResult.baseImageUser } knownMetadataUsers = featuresResult.metadataUsers - } else if !options.skipPull { - StatusPrinter.status("Pulling image", item: effectiveConfig.image) - try? runtime.pullImage(effectiveConfig.image, platform: platform) + } else { + if !options.skipPull { + StatusPrinter.status("Pulling image", item: effectiveConfig.image) + try? runtime.pullImage(effectiveConfig.image, platform: platform) + } + let applied = try FeatureContributionMerge.applyFromImage( + imageRef: effectiveConfig.image, + to: effectiveConfig, + runtime: runtime + ) + effectiveConfig = applied.config + knownMetadataUsers = applied.users } // Expand `${devcontainerId}` in feature/config mounts before volume ensure + create. @@ -341,7 +395,7 @@ public enum UpCommand { } do { - try LifecycleRunner.runCreatePath( + try LifecycleRunner.runCreatePathThroughWaitFor( containerId: id, config: effectiveConfig, runtime: runtime @@ -350,25 +404,52 @@ public enum UpCommand { throw BringUpRecovery.eligible(error) } - // Settings apply after create-path hooks; not gated on --vscode. - _ = VSCodeCustomizationsApply.applySettingsIfNeeded( - containerId: id, - config: effectiveConfig, - runtime: runtime - ) + // Settings + Ready / JSON / open / postAttach at the waitFor point. + // Remaining create-path hooks still run so delete-on-fail and the exit + // code stay correct; do not emit a later success JSON if they fail. + var readyError: Error? + var result: UpResult? + do { + _ = VSCodeCustomizationsApply.applySettingsIfNeeded( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + result = try finish( + options: options, + id: id, + name: resolved.containerName, + config: effectiveConfig, + image: effectiveConfig.image, + runtime: runtime + ) + } catch { + readyError = error + } - return try finish( - options: options, - id: id, - name: resolved.containerName, - config: effectiveConfig, - image: effectiveConfig.image, - runtime: runtime - ) + do { + try LifecycleRunner.runCreatePathAfterWaitFor( + containerId: id, + config: effectiveConfig, + runtime: runtime + ) + } catch { + throw BringUpRecovery.eligible(error) + } + if let readyError { throw readyError } + guard let result else { + throw CLIError( + code: CLIErrorCode.internalError, + message: "up finished waitFor without a result" + ) + } + return result } - /// Extensions apply (not flag-gated) → open (optional) → postAttach gate → Ready. - /// postAttach is never before open when `--vscode`. Extensions never fold into postAttachCommand. + /// Extensions apply (not flag-gated) → open (optional) → CLI-attach postAttach → Ready + /// (and `--json` success JSON). Called at the `waitFor` point, not after later hooks. + /// postAttach is never before open when `--vscode`. Open soft-fail does not skip postAttach. + /// Extensions never fold into postAttachCommand. private static func finish( options: UpOptions, id: String, @@ -403,6 +484,7 @@ public enum UpCommand { ) try LifecycleRunner.applyPostAttachGate( openOutcome: openOutcome, + kind: .cliAttach, containerId: id, config: postAttachConfig, runtime: runtime @@ -410,6 +492,10 @@ public enum UpCommand { // Connection hints are emitted by the entry point after the human outcome digest // so the terminal order is: Ready → outcome fields → blank → connect instructions. StatusPrinter.status("Ready") + try SuccessPresentation.emitSuccessJSONIfRequested( + result.jsonString(), + jsonOutput: options.jsonOutput + ) return result } diff --git a/Sources/ADevContainerLib/Config/ConfigAdmissions.swift b/Sources/ADevContainerLib/Config/ConfigAdmissions.swift index f4f34a8..e33cedd 100644 --- a/Sources/ADevContainerLib/Config/ConfigAdmissions.swift +++ b/Sources/ADevContainerLib/Config/ConfigAdmissions.swift @@ -18,6 +18,10 @@ public enum ConfigAdmissions { "updateContentCommand", "postStartCommand", "postAttachCommand", + "initializeCommand", + "waitFor", + "userEnvProbe", + "shutdownAction", "customizations", "hostRequirements", "runArgs", diff --git a/Sources/ADevContainerLib/Config/ConfigResolver.swift b/Sources/ADevContainerLib/Config/ConfigResolver.swift index 3b0bac4..2052599 100644 --- a/Sources/ADevContainerLib/Config/ConfigResolver.swift +++ b/Sources/ADevContainerLib/Config/ConfigResolver.swift @@ -186,11 +186,15 @@ public enum ConfigResolver { } } + let initialize = try LifecycleCommand.parse(raw["initializeCommand"], property: "initializeCommand") let onCreate = try LifecycleCommand.parse(raw["onCreateCommand"], property: "onCreateCommand") let updateContent = try LifecycleCommand.parse(raw["updateContentCommand"], property: "updateContentCommand") let postCreate = try LifecycleCommand.parse(raw["postCreateCommand"], property: "postCreateCommand") let postStart = try LifecycleCommand.parse(raw["postStartCommand"], property: "postStartCommand") let postAttach = try LifecycleCommand.parse(raw["postAttachCommand"], property: "postAttachCommand") + let waitFor = try WaitFor.parse(raw["waitFor"]) + let userEnvProbe = try UserEnvProbe.parse(raw["userEnvProbe"]) + let shutdownAction = try ShutdownAction.parse(raw["shutdownAction"]) let runArgs = try RunArgsAdmission.parse(raw["runArgs"]) let hostRequirements = try HostRequirements.parse(raw["hostRequirements"]) @@ -214,6 +218,10 @@ public enum ConfigResolver { updateContentCommand: updateContent, postStartCommand: postStart, postAttachCommand: postAttach, + initializeCommand: initialize, + waitFor: waitFor, + userEnvProbe: userEnvProbe, + shutdownAction: shutdownAction, runArgs: runArgs, hostRequirements: hostRequirements, hasVscodeCustomizations: vscode.hasVscode, diff --git a/Sources/ADevContainerLib/Config/DevContainerConfig.swift b/Sources/ADevContainerLib/Config/DevContainerConfig.swift index 71f50e8..045110f 100644 --- a/Sources/ADevContainerLib/Config/DevContainerConfig.swift +++ b/Sources/ADevContainerLib/Config/DevContainerConfig.swift @@ -15,7 +15,7 @@ public struct NamedLifecycleCommand: Equatable, Sendable { public enum LifecycleCommand: Equatable, Sendable { case shell(String) case argv([String]) - /// Object form: name → string or argv. Spec runs in parallel; product runs sequentially in sorted name order. + /// Object form: name → string or argv. Named entries run concurrently. case parallel([NamedLifecycleCommand]) public static func parse(_ value: Any?, property: String) throws -> LifecycleCommand? { @@ -105,6 +105,135 @@ public enum LifecycleCommand: Equatable, Sendable { } } +/// Official `waitFor` stage (omitted → `updateContentCommand`). +public enum WaitFor: String, Equatable, Sendable { + case initializeCommand + case onCreateCommand + case updateContentCommand + case postCreateCommand + case postStartCommand + + public static let officialDefault = WaitFor.updateContentCommand + + /// Host initialize through postCreate — already satisfied on reuse / resume. + public var isCreatePathStage: Bool { + self != .postStartCommand + } + + /// Inclusive index into in-container create-path stages + /// (`onCreate` = 0 … `postStart` = 3). Host `initializeCommand` is `-1` + /// (already finished before `runCreatePath`). + public var createPathInclusiveIndex: Int { + switch self { + case .initializeCommand: return -1 + case .onCreateCommand: return 0 + case .updateContentCommand: return 1 + case .postCreateCommand: return 2 + case .postStartCommand: return 3 + } + } + + public static func parse(_ value: Any?, property: String = "waitFor") throws -> WaitFor { + guard let value else { return officialDefault } + guard let raw = value as? String else { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "\(property) must be a string", + hint: "Use initializeCommand, onCreateCommand, updateContentCommand, postCreateCommand, or postStartCommand" + ) + } + guard let parsed = WaitFor(rawValue: raw) else { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "Unknown \(property) value '\(raw)'", + hint: "Use initializeCommand, onCreateCommand, updateContentCommand, postCreateCommand, or postStartCommand" + ) + } + return parsed + } +} + +/// Official `userEnvProbe` (omitted → `loginInteractiveShell`). +public enum UserEnvProbe: String, Equatable, Sendable { + case none + case interactiveShell + case loginShell + case loginInteractiveShell + + public static let officialDefault = UserEnvProbe.loginInteractiveShell + + /// `sh` dash-options for the probe (`-ic` / `-lc` / `-lic`). Nil when probing is skipped. + public var shellDashOptions: String? { + switch self { + case .none: return nil + case .interactiveShell: return "-ic" + case .loginShell: return "-lc" + case .loginInteractiveShell: return "-lic" + } + } + + public static func parse(_ value: Any?, property: String = "userEnvProbe") throws -> UserEnvProbe { + guard let value else { return officialDefault } + guard let raw = value as? String else { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "\(property) must be a string", + hint: "Use none, interactiveShell, loginShell, or loginInteractiveShell" + ) + } + guard let parsed = UserEnvProbe(rawValue: raw) else { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "Unknown \(property) value '\(raw)'", + hint: "Use none, interactiveShell, loginShell, or loginInteractiveShell" + ) + } + return parsed + } +} + +/// Official `shutdownAction` for this image product (omitted → `stopContainer`). +/// `stopCompose` is rejected at parse (Compose unsupported). +public enum ShutdownAction: String, Equatable, Sendable { + case none + case stopContainer + + public static let officialDefault = ShutdownAction.stopContainer + + public static func parse(_ value: Any?, property: String = "shutdownAction") throws -> ShutdownAction { + guard let value else { return officialDefault } + guard let raw = value as? String else { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "\(property) must be a string", + hint: "Use stopContainer or none" + ) + } + if raw == "stopCompose" { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "Docker Compose configuration is not supported", + hint: "Use \"shutdownAction\": \"stopContainer\" or \"none\" — Compose is not supported" + ) + } + guard let parsed = ShutdownAction(rawValue: raw) else { + throw CLIError( + code: CLIErrorCode.unsupportedProperty, + property: property, + message: "Unknown \(property) value '\(raw)'", + hint: "Use stopContainer or none" + ) + } + return parsed + } +} + /// Fully resolved devcontainer config ready for runtime mapping. public struct ResolvedDevContainerConfig: Equatable { public var name: String? @@ -121,6 +250,10 @@ public struct ResolvedDevContainerConfig: Equatable { public var updateContentCommand: LifecycleCommand? public var postStartCommand: LifecycleCommand? public var postAttachCommand: LifecycleCommand? + public var initializeCommand: LifecycleCommand? + public var waitFor: WaitFor + public var userEnvProbe: UserEnvProbe + public var shutdownAction: ShutdownAction /// Allowlisted runArgs mapped onto create argv. public var runArgs: [AllowlistedRunArg] /// Evaluated hostRequirements (nil when absent). @@ -155,6 +288,10 @@ public struct ResolvedDevContainerConfig: Equatable { updateContentCommand: LifecycleCommand? = nil, postStartCommand: LifecycleCommand? = nil, postAttachCommand: LifecycleCommand? = nil, + initializeCommand: LifecycleCommand? = nil, + waitFor: WaitFor = .updateContentCommand, + userEnvProbe: UserEnvProbe = .loginInteractiveShell, + shutdownAction: ShutdownAction = .stopContainer, runArgs: [AllowlistedRunArg] = [], hostRequirements: HostRequirements? = nil, hasVscodeCustomizations: Bool = false, @@ -181,6 +318,10 @@ public struct ResolvedDevContainerConfig: Equatable { self.updateContentCommand = updateContentCommand self.postStartCommand = postStartCommand self.postAttachCommand = postAttachCommand + self.initializeCommand = initializeCommand + self.waitFor = waitFor + self.userEnvProbe = userEnvProbe + self.shutdownAction = shutdownAction self.runArgs = runArgs self.hostRequirements = hostRequirements self.hasVscodeCustomizations = hasVscodeCustomizations @@ -257,7 +398,10 @@ public struct ResolvedDevContainerConfig: Equatable { if let updateContentCommand { m["updateContentCommand"] = updateContentCommand.hashEncoding } if let postCreateCommand { m["postCreateCommand"] = postCreateCommand.hashEncoding } if let postStartCommand { m["postStartCommand"] = postStartCommand.hashEncoding } - // postAttach does not affect create identity; omit from hash. + if let initializeCommand { m["initializeCommand"] = initializeCommand.hashEncoding } + if waitFor != .updateContentCommand { m["waitFor"] = waitFor.rawValue } + if userEnvProbe != .loginInteractiveShell { m["userEnvProbe"] = userEnvProbe.rawValue } + // postAttach / shutdownAction do not affect create identity; omit from hash. if !runArgs.isEmpty { m["runArgs"] = runArgs.map { $0.hashEncoding as Any } } diff --git a/Sources/ADevContainerLib/Features/DerivedImageTag.swift b/Sources/ADevContainerLib/Features/DerivedImageTag.swift index 7b6b4bc..b400f3e 100644 --- a/Sources/ADevContainerLib/Features/DerivedImageTag.swift +++ b/Sources/ADevContainerLib/Features/DerivedImageTag.swift @@ -12,11 +12,12 @@ public enum DerivedImageTag { /// /// Bump when `FeatureDockerfileGenerator` install-layer semantics change /// so `imageExists` does not reuse images built with the old recipe. - /// Current `"5"`: recursive `chmod -R 0755` before install, restore base - /// image OCI USER after installs, and install-time feature `containerEnv` - /// as Dockerfile `ENV` before each feature’s install `RUN` (so `$PATH`/ - /// `$VAR` expand; not shell-quoted on the RUN prefix). - public static let recipeVersion = "5" + /// Current `"6"`: bake unioned lifecycle (base-image metadata + features) + /// onto `devcontainer.metadata` so resume remelt does not drop base-image + /// hooks. Prior `"5"` reused a features-only LABEL (plus chmod-before-install, + /// restore base USER, and feature `containerEnv` as Dockerfile `ENV` before + /// each install `RUN`). + public static let recipeVersion = "6" public static func compute( baseImage: String, diff --git a/Sources/ADevContainerLib/Features/DevContainerMetadataLabel.swift b/Sources/ADevContainerLib/Features/DevContainerMetadataLabel.swift index 9b3e6d0..5e40cf5 100644 --- a/Sources/ADevContainerLib/Features/DevContainerMetadataLabel.swift +++ b/Sources/ADevContainerLib/Features/DevContainerMetadataLabel.swift @@ -18,6 +18,41 @@ public enum DevContainerMetadataLabel { public static let empty = ImageMetadataUsers() } + /// Load contributions and users from a local image inspect. Absence / inspect failure → empty. + public static func loadContributions( + imageRef: String, + runtime: AppleContainerRuntime + ) -> (contributions: FeatureContributions, users: ImageMetadataUsers) { + guard let labels = try? runtime.imageLabels(ref: imageRef) else { + return (.empty, .empty) + } + warnStripUnsafe(from: labels, imageRef: imageRef) + return (parseContributions(from: labels), parseUsers(from: labels)) + } + + /// Encode feature lifecycle hooks as official-style metadata fragments (JSON array). + /// Used to bake `devcontainer.metadata` onto a derived image so resume can remelt. + public static func encodeFragments(_ contributions: FeatureContributions) -> String? { + var fragments: [[String: Any]] = [] + func append(_ key: String, _ commands: [LifecycleCommand]) { + for cmd in commands { + fragments.append([key: cmd.hashEncoding]) + } + } + append("onCreateCommand", contributions.onCreateCommands) + append("updateContentCommand", contributions.updateContentCommands) + append("postCreateCommand", contributions.postCreateCommands) + append("postStartCommand", contributions.postStartCommands) + append("postAttachCommand", contributions.postAttachCommands) + guard !fragments.isEmpty else { return nil } + guard JSONSerialization.isValidJSONObject(fragments), + let data = try? JSONSerialization.data(withJSONObject: fragments), + let s = String(data: data, encoding: .utf8) else { + return nil + } + return s + } + /// Parse label JSON into partial contributions. Absence / parse failure → empty (never fails up alone). /// Accepts a top-level JSON object or an array of objects (fragments are union-merged). public static func parseContributions(from labels: [String: String]) -> FeatureContributions { diff --git a/Sources/ADevContainerLib/Features/FeatureContributionMerge.swift b/Sources/ADevContainerLib/Features/FeatureContributionMerge.swift index 6c3d500..cdc180a 100644 --- a/Sources/ADevContainerLib/Features/FeatureContributionMerge.swift +++ b/Sources/ADevContainerLib/Features/FeatureContributionMerge.swift @@ -120,6 +120,19 @@ public enum FeatureContributionMerge { return out } + /// Apply base-image `devcontainer.metadata` when Features did not run (empty features). + public static func applyFromImage( + imageRef: String, + to config: ResolvedDevContainerConfig, + runtime: AppleContainerRuntime + ) throws -> (config: ResolvedDevContainerConfig, users: DevContainerMetadataLabel.ImageMetadataUsers) { + let loaded = DevContainerMetadataLabel.loadContributions(imageRef: imageRef, runtime: runtime) + guard loaded.contributions != .empty else { + return (config, loaded.users) + } + return (try apply(contributions: loaded.contributions, to: config), loaded.users) + } + private static func isValidCapabilityName(_ name: String) -> Bool { !name.isEmpty && !name.hasPrefix("-") } diff --git a/Sources/ADevContainerLib/Features/FeatureDockerfileGenerator.swift b/Sources/ADevContainerLib/Features/FeatureDockerfileGenerator.swift index acc6fa1..51502b2 100644 --- a/Sources/ADevContainerLib/Features/FeatureDockerfileGenerator.swift +++ b/Sources/ADevContainerLib/Features/FeatureDockerfileGenerator.swift @@ -27,6 +27,7 @@ public enum FeatureDockerfileGenerator { remoteUser: String? = nil, containerUser: String? = nil, baseUser: String? = nil, + contributions: FeatureContributions? = nil, fileManager: FileManager = .default ) throws -> BuildContext { try fileManager.createDirectory(atPath: contextDirectory, withIntermediateDirectories: true) @@ -101,6 +102,14 @@ public enum FeatureDockerfileGenerator { lines.append("USER \(restoreUser)") lines.append("") + // Persist unioned lifecycle hooks (base-image metadata + features) so resume remelt + // does not require a Features rebuild and does not drop base-image fragments. + let baked = contributions ?? (try? FeatureContributionMerge.collect(from: ordered)) + if let baked, let json = DevContainerMetadataLabel.encodeFragments(baked) { + lines.append("LABEL \(DevContainerMetadataLabel.labelKey)=\(dockerfileLabelValue(json))") + lines.append("") + } + let contents = lines.joined(separator: "\n") let dockerfilePath = (contextDirectory as NSString).appendingPathComponent("Dockerfile") try contents.write(toFile: dockerfilePath, atomically: true, encoding: .utf8) @@ -112,6 +121,15 @@ public enum FeatureDockerfileGenerator { ) } + /// Dockerfile `LABEL` value: double-quoted, with `\`, `"`, and `$` escaped. + private static func dockerfileLabelValue(_ raw: String) -> String { + let escaped = raw + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "$", with: "\\$") + return "\"\(escaped)\"" + } + private static func copyPackage(from source: String, to dest: String, fileManager: FileManager) throws { try fileManager.createDirectory(atPath: dest, withIntermediateDirectories: true) let contents = try fileManager.contentsOfDirectory(atPath: source) diff --git a/Sources/ADevContainerLib/Features/FeaturesRunner.swift b/Sources/ADevContainerLib/Features/FeaturesRunner.swift index d518002..e84610c 100644 --- a/Sources/ADevContainerLib/Features/FeaturesRunner.swift +++ b/Sources/ADevContainerLib/Features/FeaturesRunner.swift @@ -209,6 +209,7 @@ public enum FeaturesRunner { remoteUser: remoteUser, containerUser: containerUser, baseUser: baseUser, + contributions: contributions, fileManager: deps.fileManager ) diff --git a/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift b/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift index 561a9bd..2690058 100644 --- a/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift +++ b/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift @@ -1294,7 +1294,13 @@ printf 'RECOVERY_APPLIED:%s\n' "$actual" ?? [:] var image: String? if let imageObj = configuration["image"] as? [String: Any] { - image = imageObj["reference"] as? String + image = imageObj["reference"] as? String ?? imageObj["id"] as? String + } + if image == nil { + image = configuration["image"] as? String ?? obj["image"] as? String + } + if let trimmed = image?.trimmingCharacters(in: .whitespacesAndNewlines), trimmed.isEmpty { + image = nil } return ContainerInfo( id: id, diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index c525cc4..66ab5c4 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -228,7 +228,7 @@ public enum CommandSurface { up [-w path] Create/start/reuse bind-mode dev container (host path) clone Clone repo into volume-mode dev container (managed) list [--json] List managed dev containers (up + clone) - start [--name] Start a stopped managed dev container (no hooks on volume-mode; --json suppresses recovery prompt) + start [--name] Start a stopped managed container (initialize + postStart on real start; --json suppresses recovery prompt) exec [-it] [--name] [--] [cmd...] Run a command (or shell) in a managed dev container stop [--name] Stop a managed dev container (name or picker) delete [--name] Remove container only (not workspace volume) @@ -244,7 +244,7 @@ public enum CommandSurface { --json Machine-readable output (up, clone, list, rebuild); on start, suppresses the interactive recovery prompt --skip-pull Skip image pull on up/clone/rebuild - --vscode Best-effort open VS Code; gates postAttach (not apply) + --vscode Best-effort open VS Code (not apply). postAttach is CLI attach except already-running start -h, --help Show help Identity: @@ -262,11 +262,17 @@ public enum CommandSurface { idempotency; Server extensions.json + transitive extensionDependencies ∪ extensionPack. Not gated on --vscode or open success. - adevcontainer start does not apply settings or extensions (with or without - --vscode) and does not run postStart. - - Order with --vscode on up / clone / rebuild: apply (if pending) → open → - postAttach (postAttach only after successful open). - - postAttachCommand runs only after successful open; skipped without flag or on open - soft-fail (status when present). postAttach non-zero fails command but keeps container. + --vscode). Real start runs initialize (when a host workspace exists), then + config postStart and remelted feature postStart. Already-running is a no-op + for those hooks. + - Order with --vscode on up / clone / rebuild / real start: apply (if pending; + not on start) → open → postAttach. postAttach is CLI attach on those paths + (open soft-fail does not skip it). On already-running start, postAttach + requires a successful --vscode open. + - postAttachCommand runs as CLI attach at the end of up / clone / rebuild and + after a real start. Already-running start requires a successful --vscode + open (skip status when present and open did not succeed). postAttach non-zero + fails command but keeps container. - Manual attach without a CLI apply command does not install. CLI attach approximation only — not IDE remote-ready. Not full extension parity. @@ -320,13 +326,12 @@ public enum CommandSurface { --vscode: best-effort open a new VS Code window on the remote workspace folder (requires VS Code with Remote - Containers and a `code` CLI). Soft-fails with - a stderr warning; open alone does not fail up. --vscode gates open and - postAttach only, not apply. + a stderr warning; open alone does not fail up. --vscode gates open only, not + apply. postAttach is CLI attach after waitFor (not --vscode-gated). Settings and extensions from customizations.vscode apply by default after create-path hooks (and on reuse / start-stopped when the marker is pending). - Order with --vscode: apply → open → postAttach. postAttach only after - successful open; skipped without flag / open soft-fail; postAttach failure - fails up but keeps the container. + Order with --vscode: apply → open → postAttach. Open soft-fail does not skip + postAttach. postAttach failure fails up but keeps the container. Apply is soft-fail with marker skip when matched. Not full Dev Containers parity — manual attach remains valid. @@ -357,7 +362,8 @@ public enum CommandSurface { Used to retry a failed clone after editing the retained devcontainer.json. --vscode: best-effort open VS Code on the resolved remote folder after - success (same prereqs/soft-fail/open/postAttach gate as up). Settings and + success (same prereqs/soft-fail as up). postAttach is CLI attach after + waitFor (not --vscode-gated). Settings and extensions from customizations.vscode apply by default after create-path hooks (not gated on the flag). Not full extension parity. @@ -373,13 +379,15 @@ public enum CommandSurface { return """ adevcontainer start [--name ] [--vscode] [--json] - Start a stopped managed container. Volume-mode: runtime start only - (no lifecycle hooks). Already running is success no-op. + Start a stopped managed container. Real start runs host initializeCommand + (when a host workspace exists), then config postStartCommand and remelted + feature postStart. Already running is success no-op for those hooks. --vscode: best-effort open VS Code on the labeled remote workspace folder after - start (inspect for id/image/folder). Soft-fail open. postAttach after open - success. start does not apply settings or extensions (with or without - --vscode) and does not run postStart. If runtime start fails, a TTY prompts + start (inspect for id/image/folder). Soft-fail open. postAttach runs after a + real start even without --vscode; on already-running start it requires a + successful --vscode open. start does not apply settings or extensions (with or + without --vscode). If runtime start fails, a TTY prompts (default Y) and delegates to `rebuild --name `; decline/EOF, non-TTY, and --json fail with that exact rebuild hint. Start never opens an editor or retries start. Not full extension parity. @@ -444,7 +452,8 @@ public enum CommandSurface { - Named rebuild --name retry skips the Y/n prompt. --vscode: best-effort open VS Code on the resolved remote folder after - success (open + postAttach only; same soft-fail/open/postAttach gate as up). + success (open only; same soft-fail as up). postAttach is CLI attach on the + new container (not --vscode-gated; open soft-fail does not skip it). customizations.vscode settings and extensions apply by default on the new container after create-path hooks (not gated on the flag). --json: machine-readable success output (up-shape; volume mode may add diff --git a/Sources/ADevContainerLib/Support/SuccessPresentation.swift b/Sources/ADevContainerLib/Support/SuccessPresentation.swift index 5073c94..d51e333 100644 --- a/Sources/ADevContainerLib/Support/SuccessPresentation.swift +++ b/Sources/ADevContainerLib/Support/SuccessPresentation.swift @@ -17,6 +17,33 @@ import Glibc /// Open in VS Code with: … /// ``` public enum SuccessPresentation { + /// Test seam for success JSON (default: process stdout). + nonisolated(unsafe) public static var writeStdout: ((Data) -> Void)? + + /// Set when a command writes success JSON at the `waitFor` point. The entry + /// point must not print a second copy — including if a later hook then fails. + nonisolated(unsafe) public static var didEmitSuccessJSON = false + + /// Machine-readable success JSON at the `waitFor` connection point (not at process exit). + public static func emitSuccessJSON(_ json: String) { + let payload = json.hasSuffix("\n") ? json : json + "\n" + let data = Data(payload.utf8) + if let writeStdout { + writeStdout(data) + } else { + FileHandle.standardOutput.write(data) + } + #if canImport(Darwin) || canImport(Glibc) + fflush(nil) + #endif + didEmitSuccessJSON = true + } + + public static func emitSuccessJSONIfRequested(_ json: String, jsonOutput: Bool) { + guard jsonOutput else { return } + emitSuccessJSON(json) + } + /// Human key/value digest on stdout (not used for `--json`). public static func emitHumanDigest( outcome: String, diff --git a/Sources/adevcontainer/AdevcontainerMain.swift b/Sources/adevcontainer/AdevcontainerMain.swift index 0fe0895..2a06344 100644 --- a/Sources/adevcontainer/AdevcontainerMain.swift +++ b/Sources/adevcontainer/AdevcontainerMain.swift @@ -68,7 +68,10 @@ struct AdevcontainerMain { ) let result = try UpCommand.run(options: opts, runtime: runtime) if opts.jsonOutput { - print(try result.jsonString()) + // Success JSON is emitted at the waitFor point inside the command. + if !SuccessPresentation.didEmitSuccessJSON { + print(try result.jsonString()) + } } else { SuccessPresentation.emitHumanDigest(result) } @@ -99,7 +102,9 @@ struct AdevcontainerMain { ) let result = try CloneCommand.run(options: opts, runtime: runtime) if opts.jsonOutput { - print(try result.jsonString()) + if !SuccessPresentation.didEmitSuccessJSON { + print(try result.jsonString()) + } } else { SuccessPresentation.emitHumanDigest(result) } @@ -175,7 +180,9 @@ struct AdevcontainerMain { let opts = parsed.rebuildOptions() let result = try RebuildCommand.run(options: opts, runtime: runtime) if opts.jsonOutput { - print(try result.jsonString()) + if !SuccessPresentation.didEmitSuccessJSON { + print(try result.jsonString()) + } } else { SuccessPresentation.emitHumanDigest(result) } diff --git a/Tests/adevcontainerTests/AllCommandTests.swift b/Tests/adevcontainerTests/AllCommandTests.swift index 71e72bc..481068b 100644 --- a/Tests/adevcontainerTests/AllCommandTests.swift +++ b/Tests/adevcontainerTests/AllCommandTests.swift @@ -332,7 +332,9 @@ nonisolated(unsafe) let execTests: [(String, () throws -> Void)] = [ runtime: runtime ) try MiniTest.expectEqual(code, 0) - let execCall = mock.calls.first { $0.arguments.first == "exec" }! + let execCall = mock.calls.first { + $0.arguments.first == "exec" && $0.arguments.contains("echo") + }! try MiniTest.expect(execCall.arguments.contains("vscode")) try MiniTest.expect(execCall.arguments.contains("/workspaces/app")) try MiniTest.expect(execCall.arguments.contains("echo")) @@ -376,7 +378,9 @@ nonisolated(unsafe) let execTests: [(String, () throws -> Void)] = [ runtime: runtime ) try MiniTest.expectEqual(code, 0) - let execCall = mock.calls.first { $0.arguments.first == "exec" }! + let execCall = mock.calls.first { + $0.arguments.first == "exec" && $0.arguments.contains("USER_EXEC_MARK") + }! // No streamOutput framing path for user exec. try MiniTest.expect(execCall.streamStderr != true) try MiniTest.expect(execCall.teeStdoutToStderr != true) @@ -427,7 +431,12 @@ nonisolated(unsafe) let execTests: [(String, () throws -> Void)] = [ ) try MiniTest.expectEqual(code, 0) try MiniTest.expect(listMock.calls.contains { $0.arguments.first == "list" }) - try MiniTest.expect(!listMock.calls.contains { $0.arguments.first == "exec" }) + try MiniTest.expect( + listMock.calls.filter { $0.arguments.first == "exec" }.allSatisfy { + $0.arguments.contains(LifecycleRunner.userEnvProbeScript) + }, + "non-interactive runner may probe; user exec stays on the interactive runner" + ) let execCall = interactiveMock.calls.first { $0.arguments.first == "exec" }! try MiniTest.expect(execCall.arguments.contains("-i")) try MiniTest.expect(execCall.arguments.contains("-t")) @@ -1723,6 +1732,9 @@ nonisolated(unsafe) let phase3Tests: [(String, () throws -> Void)] = [ return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return ProcessResult(exitCode: 7, stdout: Data(), stderr: Data("failed\n".utf8)) } return nil @@ -1775,6 +1787,9 @@ nonisolated(unsafe) let phase3Tests: [(String, () throws -> Void)] = [ return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return ProcessResult(exitCode: 7, stdout: Data(), stderr: Data("failed\n".utf8)) } if args.first == "delete" { @@ -1858,8 +1873,166 @@ private enum LifecycleUpSupport { return args } + static func execBody(_ args: [String]) -> String? { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + return args[lc + 1] + } + return nil + } + + static func isUserEnvProbeExec(_ args: [String]) -> Bool { + args.contains("cat /proc/self/environ") + } + + static func execEnv(_ args: [String]) -> [String: String] { + var env: [String: String] = [:] + var index = 0 + while index < args.count { + if args[index] == "-e", index + 1 < args.count { + let pair = args[index + 1] + if let eq = pair.firstIndex(of: "=") { + env[String(pair[.. String? { + guard let index = args.firstIndex(of: "-u"), index + 1 < args.count else { return nil } + return args[index + 1] + } + + static let probedVariableName = "ADEV_PROBE_VAR" + static let probedVariableValue = "from-login-interactive" + static let probedEnvironStdout = "\(probedVariableName)=\(probedVariableValue)\n" + + static func mockFreshCreateThenRunning( + resolved: ResolvedWorkspace, + execHandler: @escaping ([String]) -> ProcessResult = { _ in + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ) -> MockProcessRunner { + let mock = MockProcessRunner() + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "running", + labels: resolved.labels + ) + var created = false + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let payload: [Any] = created ? [entry] : [] + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "create" { + created = true + return ProcessResult( + exitCode: 0, + stdout: Data("\(resolved.containerName)\n".utf8), + stderr: Data() + ) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "exec" { + return execHandler(args) + } + if args.first == "delete" { + created = false + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + return mock + } + + /// Capture Ready (stderr) and success JSON (stdout) while a hook is latched. + final class WaitForIO: @unchecked Sendable { + private let lock = NSLock() + private var stderrText = "" + private var stdoutText = "" + + var stderr: String { + lock.lock() + defer { lock.unlock() } + return stderrText + } + + var stdout: String { + lock.lock() + defer { lock.unlock() } + return stdoutText + } + + var sawReady: Bool { stderr.contains("Ready") } + + var sawSuccessJSON: Bool { + let out = stdout + return out.contains("\"outcome\"") + && out.contains("\"containerId\"") + && out.contains("\"remoteWorkspaceFolder\"") + } + + func install() -> () -> Void { + let previousEnabled = StatusPrinter.enabled + let previousWrite = StatusPrinter.writeStderr + let previousPhase = StatusPrinter.hasEmittedPhase + let previousStdout = SuccessPresentation.writeStdout + let previousEmitted = SuccessPresentation.didEmitSuccessJSON + StatusPrinter.enabled = true + StatusPrinter.hasEmittedPhase = false + StatusPrinter.writeStderr = { [weak self] data in + guard let self else { return } + self.lock.lock() + self.stderrText += String(data: data, encoding: .utf8) ?? "" + self.lock.unlock() + } + SuccessPresentation.writeStdout = { [weak self] data in + guard let self else { return } + self.lock.lock() + self.stdoutText += String(data: data, encoding: .utf8) ?? "" + self.lock.unlock() + } + SuccessPresentation.didEmitSuccessJSON = false + return { + StatusPrinter.enabled = previousEnabled + StatusPrinter.writeStderr = previousWrite + StatusPrinter.hasEmittedPhase = previousPhase + SuccessPresentation.writeStdout = previousStdout + SuccessPresentation.didEmitSuccessJSON = previousEmitted + } + } + } + + final class RunBox: @unchecked Sendable { + let lock = NSLock() + var value: Value? + var error: Error? + + func succeed(_ value: Value) { + lock.lock() + self.value = value + lock.unlock() + } + + func fail(_ error: Error) { + lock.lock() + self.error = error + lock.unlock() + } + } + static func mockFreshCreate( resolved: ResolvedWorkspace, + succeedUserEnvProbe: Bool = true, execHandler: @escaping ([String]) -> ProcessResult = { _ in ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } @@ -1882,6 +2055,9 @@ private enum LifecycleUpSupport { return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "exec" { + if succeedUserEnvProbe, isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return execHandler(args) } if args.first == "delete" { @@ -2044,6 +2220,68 @@ nonisolated(unsafe) let phase4CommandTests: [(String, () throws -> Void)] = [ $0.arguments.first == "delete" && $0.arguments.contains(resolved.containerName) }) }), + ("upStartStoppedRemeltsFeaturePostStart", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "onCreateCommand": "echo onCreate", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo config-postStart" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "stopped", + labels: resolved.labels, + image: "alpine:3.20" + ) + let metaJSON = #"[{"postStartCommand":"echo feature-from-image"}]"# + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [DevContainerMetadataLabel.labelKey: metaJSON] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(execBodies, ["echo config-postStart", "echo feature-from-image"]) + try MiniTest.expect(!execBodies.contains("echo onCreate")) + try MiniTest.expect(!execBodies.contains("echo updateContent")) + try MiniTest.expect(!execBodies.contains("echo postCreate")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + }), ("restartPostStartFailureDoesNotDelete", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { @@ -2067,6 +2305,9 @@ nonisolated(unsafe) let phase4CommandTests: [(String, () throws -> Void)] = [ return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return ProcessResult(exitCode: 5, stdout: Data(), stderr: Data("fail\n".utf8)) } if args.first == "delete" { @@ -2089,19 +2330,27 @@ nonisolated(unsafe) let phase4CommandTests: [(String, () throws -> Void)] = [ } try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), - ("postAttachAdmittedButNotRunOnUp", { - // Capture StatusPrinter by temporarily enabling and... we can't easily capture stderr. - // Verify: postAttach body never appears in exec; up succeeds even if postAttach would fail. + ("upRunsInitializeCommandOnHostBeforeCreate", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99", - "postCreateCommand": "echo postCreate" + "initializeCommand": "echo init-host", + "onCreateCommand": "echo onCreate", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo postStart" } """) defer { try? FileManager.default.removeItem(at: ws) } let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) - try MiniTest.expect(resolved.config.postAttachCommand != nil) + var events: [String] = [] + let host = RecordingHostProcessRunner() + host.handler = { _ in + events.append("initialize") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } var execBodies: [String] = [] let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) { args in if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { @@ -2109,78 +2358,153 @@ nonisolated(unsafe) let phase4CommandTests: [(String, () throws -> Void)] = [ } return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } + let previousCreate = mock.handlers + mock.handlers = [ + { args in + if args.first == "create" { + events.append("create") + } + return nil + } + ] + previousCreate let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) let result = try UpCommand.run( options: UpOptions(workspacePath: ws.path, skipPull: true), runtime: runtime, - localEnv: [:] + localEnv: [:], + hostResources: MockHostResourceInfo(physicalMemoryBytes: 64 << 30, cpuCount: 16) ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expectEqual(execBodies, ["echo postCreate"]) - try MiniTest.expect(!execBodies.contains(where: { $0.contains("exit 99") })) - // postAttach skip goes through StatusPrinter (enabled=false in suite); property admitted - // and not executed is the behavioral contract under test. - try MiniTest.expect(resolved.config.postAttachCommand != nil) + try MiniTest.expectEqual(events, ["initialize", "create"]) + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expectEqual( + (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (ws.path as NSString).standardizingPath + ) + try MiniTest.expect(host.calls[0].arguments.contains("echo init-host")) + try MiniTest.expectEqual(execBodies, [ + "echo onCreate", + "echo updateContent", + "echo postCreate", + "echo postStart" + ]) }), - ("createThenReuseStableWithHooks", { - let ws = try TestRepo.makeTempWorkspace(configJSON: LifecycleUpSupport.fullHooksJSON) + ("upReuseStillRunsInitializeCommandOnHost", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-reuse", + "onCreateCommand": "echo onCreate", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo postStart" + } + """) defer { try? FileManager.default.removeItem(at: ws) } let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) - var alive = false + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } let entry = MockProcessRunner.containerListJSON( id: resolved.containerName, state: "running", labels: resolved.labels ) - var execCount = 0 let mock = MockProcessRunner() mock.handlers = [ { args in if args.starts(with: ["list"]) { - let payload: [Any] = alive ? [entry] : [] - let data = try! JSONSerialization.data(withJSONObject: payload) + let data = try! JSONSerialization.data(withJSONObject: [entry]) return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) } - if args.first == "create" { - alive = true - return ProcessResult( - exitCode: 0, - stdout: Data("\(resolved.containerName)\n".utf8), - stderr: Data() - ) + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(host.calls[0].arguments.contains("echo init-reuse")) + try MiniTest.expectEqual( + (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (ws.path as NSString).standardizingPath + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "exec" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "stop" }) + }), + ("upStartStoppedRunsHostInitializeThenPostStart", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-start", + "onCreateCommand": "echo onCreate", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo postStart" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var events: [String] = [] + let host = RecordingHostProcessRunner() + host.handler = { _ in + events.append("initialize") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "stopped", labels: resolved.labels + ) + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) } if args.first == "start" { + events.append("start") return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "exec" { - execCount += 1 + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } return nil } ] let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let first = try UpCommand.run( - options: UpOptions(workspacePath: ws.path, skipPull: true), - runtime: runtime, - localEnv: [:] - ) - try MiniTest.expectEqual(first.outcome, "success") - try MiniTest.expectEqual(execCount, 4) // full hook order - let second = try UpCommand.run( + let result = try UpCommand.run( options: UpOptions(workspacePath: ws.path, skipPull: true), runtime: runtime, localEnv: [:] ) - try MiniTest.expectEqual(second.outcome, "success") - try MiniTest.expectEqual(execCount, 4) // no additional hooks on reuse + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(events, ["initialize", "start"]) + try MiniTest.expectEqual(execBodies, ["echo postStart"]) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) }), - ("hostRequirementsShortfallFailsUp", { + ("initializeCommandFailureBlocksCreate", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "hostRequirements": { "memory": "512gb", "cpus": 999 } + "initializeCommand": "exit 7", + "onCreateCommand": "echo should-not-run" } """) defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + host.exitCode = 7 + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) @@ -2188,54 +2512,818 @@ nonisolated(unsafe) let phase4CommandTests: [(String, () throws -> Void)] = [ _ = try UpCommand.run( options: UpOptions(workspacePath: ws.path, skipPull: true), runtime: runtime, - localEnv: [:], - hostResources: MockHostResourceInfo(physicalMemoryBytes: 1 << 30, cpuCount: 1) + localEnv: [:] ) }) { error in let err = error as! CLIError - try MiniTest.expectEqual(err.code, CLIErrorCode.hostRequirements) - try MiniTest.expect(err.message.contains("memory") || err.message.contains("cpus")) + try MiniTest.expectEqual(err.code, CLIErrorCode.lifecycleFailed) + try MiniTest.expectEqual(err.property, "initializeCommand") } + try MiniTest.expectEqual(host.calls.count, 1) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), - ("hostRequirementsEnoughSucceedsWithCreateLimits", { + ("postAttachRunsOnUpWithoutVSCode", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "hostRequirements": { "memory": "8gb", "cpus": 2 } + "postAttachCommand": "echo postAttach-up", + "postCreateCommand": "echo postCreate" } """) defer { try? FileManager.default.removeItem(at: ws) } let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) - let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) + try MiniTest.expect(resolved.config.postAttachCommand != nil) + var execBodies: [String] = [] + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) { args in + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) let result = try UpCommand.run( options: UpOptions(workspacePath: ws.path, skipPull: true), runtime: runtime, - localEnv: [:], - hostResources: MockHostResourceInfo(physicalMemoryBytes: 64 << 30, cpuCount: 16) + localEnv: [:] ) try MiniTest.expectEqual(result.outcome, "success") - guard let createArgs = mock.calls.first(where: { $0.arguments.first == "create" })?.arguments else { - throw MiniTest.Failure(message: "expected create call") - } - if let i = createArgs.firstIndex(of: "-m") { - try MiniTest.expectEqual(createArgs[i + 1], "8G") - } else { - throw MiniTest.Failure(message: "expected -m in create argv") - } - if let i = createArgs.firstIndex(of: "-c") { - try MiniTest.expectEqual(createArgs[i + 1], "2") - } else { - throw MiniTest.Failure(message: "expected -c in create argv") + try MiniTest.expect(execBodies.contains("echo postAttach-up")) + try MiniTest.expect(execBodies.contains("echo postCreate")) + try MiniTest.expect(resolved.config.postAttachCommand != nil) + }), + ("createThenReuseStableWithHooks", { + let ws = try TestRepo.makeTempWorkspace(configJSON: LifecycleUpSupport.fullHooksJSON) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var alive = false + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "running", labels: resolved.labels + ) + var execCount = 0 + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let payload: [Any] = alive ? [entry] : [] + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "create" { + alive = true + return ProcessResult( + exitCode: 0, + stdout: Data("\(resolved.containerName)\n".utf8), + stderr: Data() + ) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "exec" { + if !LifecycleUpSupport.isUserEnvProbeExec(args) { + execCount += 1 + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let first = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(first.outcome, "success") + try MiniTest.expectEqual(execCount, 4) // full hook order + let second = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(second.outcome, "success") + try MiniTest.expectEqual(execCount, 4) // no additional hooks on reuse + }), + ("hostRequirementsShortfallFailsUp", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "hostRequirements": { "memory": "512gb", "cpus": 999 } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + _ = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:], + hostResources: MockHostResourceInfo(physicalMemoryBytes: 1 << 30, cpuCount: 1) + ) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.hostRequirements) + try MiniTest.expect(err.message.contains("memory") || err.message.contains("cpus")) + } + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + }), + ("hostRequirementsEnoughSucceedsWithCreateLimits", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "hostRequirements": { "memory": "8gb", "cpus": 2 } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:], + hostResources: MockHostResourceInfo(physicalMemoryBytes: 64 << 30, cpuCount: 16) + ) + try MiniTest.expectEqual(result.outcome, "success") + guard let createArgs = mock.calls.first(where: { $0.arguments.first == "create" })?.arguments else { + throw MiniTest.Failure(message: "expected create call") + } + if let i = createArgs.firstIndex(of: "-m") { + try MiniTest.expectEqual(createArgs[i + 1], "8G") + } else { + throw MiniTest.Failure(message: "expected -m in create argv") + } + if let i = createArgs.firstIndex(of: "-c") { + try MiniTest.expectEqual(createArgs[i + 1], "2") + } else { + throw MiniTest.Failure(message: "expected -c in create argv") + } + }), + ("hostRequirementsNoLongerSilentlyIgnored", { + let req = try HostRequirements.parse(["memory": "8gb"] as [String: Any])! + let host = MockHostResourceInfo(physicalMemoryBytes: 1 << 30, cpuCount: 8) + let eval = HostRequirementsEvaluation.evaluate(req, host: host) + try MiniTest.expect(eval.hasHardFailures) + try MiniTest.expect(eval.hardFailures.contains { $0.contains("memory") }) + }), + ("defaultWaitForAllowsReadyBeforePostCreate", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo postStart" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expectEqual(resolved.config.waitFor, .updateContentCommand) + + let io = LifecycleUpSupport.WaitForIO() + let restoreIO = io.install() + defer { restoreIO() } + + let postCreateStarted = DispatchSemaphore(value: 0) + let postCreateRelease = DispatchSemaphore(value: 0) + let runDone = DispatchSemaphore(value: 0) + let lock = NSLock() + var execBodies: [String] = [] + let box = LifecycleUpSupport.RunBox() + + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) { args in + if let body = LifecycleUpSupport.execBody(args) { + lock.lock() + execBodies.append(body) + lock.unlock() + if body == "echo postCreate" { + postCreateStarted.signal() + _ = postCreateRelease.wait(timeout: .now() + 5) + } + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + DispatchQueue.global(qos: .userInitiated).async { + do { + box.succeed(try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + )) + } catch { + box.fail(error) + } + runDone.signal() + } + defer { postCreateRelease.signal() } + try MiniTest.expect( + postCreateStarted.wait(timeout: .now() + 5) == .success, + "postCreate must start so Ready can be observed before it returns" + ) + try MiniTest.expect(io.sawReady, "Ready must appear after updateContent and before postCreate returns") + postCreateRelease.signal() + try MiniTest.expect(runDone.wait(timeout: .now() + 5) == .success, "up must finish after remaining hooks") + if let runError = box.error { + throw MiniTest.Failure(message: "up failed: \(runError)") + } + try MiniTest.expectEqual(box.value?.outcome, "success") + lock.lock() + let bodies = execBodies + lock.unlock() + try MiniTest.expectEqual(bodies, [ + "echo updateContent", + "echo postCreate", + "echo postStart" + ]) + }), + ("waitForPostCreateDelaysReadyUntilPostCreate", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "waitFor": "postCreateCommand", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo postStart" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expectEqual(resolved.config.waitFor, .postCreateCommand) + + let io = LifecycleUpSupport.WaitForIO() + let restoreIO = io.install() + defer { restoreIO() } + + let postCreateStarted = DispatchSemaphore(value: 0) + let postCreateRelease = DispatchSemaphore(value: 0) + let runDone = DispatchSemaphore(value: 0) + let lock = NSLock() + var execBodies: [String] = [] + let box = LifecycleUpSupport.RunBox() + + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) { args in + if let body = LifecycleUpSupport.execBody(args) { + lock.lock() + execBodies.append(body) + lock.unlock() + if body == "echo postCreate" { + postCreateStarted.signal() + _ = postCreateRelease.wait(timeout: .now() + 5) + } + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + DispatchQueue.global(qos: .userInitiated).async { + do { + box.succeed(try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + )) + } catch { + box.fail(error) + } + runDone.signal() + } + defer { postCreateRelease.signal() } + try MiniTest.expect( + postCreateStarted.wait(timeout: .now() + 5) == .success, + "postCreate must start" + ) + try MiniTest.expect( + !io.sawReady, + "Ready / open / postAttach must wait until postCreate finishes" + ) + postCreateRelease.signal() + try MiniTest.expect(runDone.wait(timeout: .now() + 5) == .success, "up must finish") + if let runError = box.error { + throw MiniTest.Failure(message: "up failed: \(runError)") + } + try MiniTest.expectEqual(box.value?.outcome, "success") + try MiniTest.expect(io.sawReady, "Ready must emit after postCreate") + lock.lock() + let bodies = execBodies + lock.unlock() + try MiniTest.expectEqual(bodies, [ + "echo postCreate", + "echo postStart" + ]) + }), + ("successJSONWaitsForWaitForNotLaterHooks", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + + let io = LifecycleUpSupport.WaitForIO() + let restoreIO = io.install() + defer { restoreIO() } + + let postCreateStarted = DispatchSemaphore(value: 0) + let postCreateRelease = DispatchSemaphore(value: 0) + let runDone = DispatchSemaphore(value: 0) + var jsonBeforeUpdateContent = false + let box = LifecycleUpSupport.RunBox() + + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) { args in + guard let body = LifecycleUpSupport.execBody(args) else { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if body == "echo updateContent" { + jsonBeforeUpdateContent = io.sawSuccessJSON + } + if body == "echo postCreate" { + postCreateStarted.signal() + _ = postCreateRelease.wait(timeout: .now() + 5) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + DispatchQueue.global(qos: .userInitiated).async { + do { + box.succeed(try UpCommand.run( + options: UpOptions(workspacePath: ws.path, jsonOutput: true, skipPull: true), + runtime: runtime, + localEnv: [:] + )) + } catch { + box.fail(error) + } + runDone.signal() + } + defer { postCreateRelease.signal() } + try MiniTest.expect( + postCreateStarted.wait(timeout: .now() + 5) == .success, + "postCreate must start so JSON can be observed before it finishes" + ) + try MiniTest.expect(!jsonBeforeUpdateContent, "success JSON must not appear before updateContent") + try MiniTest.expect(io.sawSuccessJSON, "success JSON may appear before postCreate finishes") + postCreateRelease.signal() + try MiniTest.expect(runDone.wait(timeout: .now() + 5) == .success, "process must wait for remaining hooks") + if let runError = box.error { + throw MiniTest.Failure(message: "up failed: \(runError)") + } + try MiniTest.expectEqual(box.value?.outcome, "success") + try MiniTest.expect(io.sawSuccessJSON) + try MiniTest.expect(io.stdout.contains(resolved.containerName)) + }), + ("backgroundCreatePathHookFailureStillDeletes", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "exit 7" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + + let io = LifecycleUpSupport.WaitForIO() + let restoreIO = io.install() + defer { restoreIO() } + + var readyBeforePostCreateFail = false + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved) { args in + if let body = LifecycleUpSupport.execBody(args), body == "exit 7" { + readyBeforePostCreateFail = io.sawReady + return ProcessResult(exitCode: 7, stdout: Data(), stderr: Data("boom\n".utf8)) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + _ = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, jsonOutput: true, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.postCreateFailed) + try MiniTest.expectEqual(err.property, "postCreateCommand") + } + try MiniTest.expect(readyBeforePostCreateFail, "Ready may already be emitted when postCreate fails") + try MiniTest.expect(io.sawSuccessJSON, "success JSON may already be emitted at waitFor") + try MiniTest.expect(mock.calls.contains { + $0.arguments.first == "delete" && $0.arguments.contains(resolved.containerName) + }) + }), + ("resumeDoesNotReWaitCreatePathWaitFor", { + let ws = try TestRepo.makeTempWorkspace(configJSON: LifecycleUpSupport.fullHooksJSON) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + + let io = LifecycleUpSupport.WaitForIO() + let restoreIO = io.install() + defer { restoreIO() } + + let postStartStarted = DispatchSemaphore(value: 0) + let postStartRelease = DispatchSemaphore(value: 0) + let runDone = DispatchSemaphore(value: 0) + let lock = NSLock() + var execBodies: [String] = [] + let box = LifecycleUpSupport.RunBox() + + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "stopped", labels: resolved.labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "exec" { + if let body = LifecycleUpSupport.execBody(args) { + lock.lock() + execBodies.append(body) + lock.unlock() + if body == "echo postStart" { + postStartStarted.signal() + _ = postStartRelease.wait(timeout: .now() + 5) + } + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + DispatchQueue.global(qos: .userInitiated).async { + do { + box.succeed(try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + )) + } catch { + box.fail(error) + } + runDone.signal() + } + defer { postStartRelease.signal() } + try MiniTest.expect( + postStartStarted.wait(timeout: .now() + 5) == .success, + "resume postStart must still run" + ) + try MiniTest.expect( + io.sawReady, + "default waitFor must not block Ready on create-path stages during resume" + ) + postStartRelease.signal() + try MiniTest.expect(runDone.wait(timeout: .now() + 5) == .success, "up start-stopped must finish") + if let runError = box.error { + throw MiniTest.Failure(message: "up start-stopped failed: \(runError)") + } + try MiniTest.expectEqual(box.value?.outcome, "success") + lock.lock() + let upBodies = execBodies + lock.unlock() + try MiniTest.expectEqual(upBodies, ["echo postStart"]) + + // Bare start: create-path waitFor is already satisfied; do not re-exec those stages. + let startLabels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let startEntry = MockProcessRunner.containerListJSON( + id: "adev-app-waitfor-start", + state: "stopped", + labels: startLabels + ) + var startBodies: [String] = [] + let startMock = MockProcessRunner() + startMock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [startEntry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: startEntry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let body = LifecycleUpSupport.execBody(args) { + startBodies.append(body) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let startRuntime = AppleContainerRuntime( + executablePath: "/usr/local/bin/container", + runner: startMock + ) + try StartCommand.run( + options: StartOptions(name: "adev-app-waitfor-start"), + runtime: startRuntime + ) + try MiniTest.expect(startMock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(startBodies.contains("echo postStart")) + try MiniTest.expect(!startBodies.contains("echo onCreate")) + try MiniTest.expect(!startBodies.contains("echo updateContent")) + try MiniTest.expect(!startBodies.contains("echo postCreate")) + }), + ("defaultProbeMergesIntoPostCreateAndExec", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postCreateCommand": "echo postCreate" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expectEqual(resolved.config.userEnvProbe, .loginInteractiveShell) + + var probeExecs = 0 + let mock = LifecycleUpSupport.mockFreshCreateThenRunning(resolved: resolved) { args in + if LifecycleUpSupport.isUserEnvProbeExec(args) { + probeExecs += 1 + return ProcessResult( + exitCode: 0, + stdout: Data(LifecycleUpSupport.probedEnvironStdout.utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect(probeExecs > 0, "omitted userEnvProbe must probe login-interactive env") + + let postCreateExec = mock.calls.first { call in + call.arguments.first == "exec" + && LifecycleUpSupport.execBody(call.arguments) == "echo postCreate" + } + guard let postCreateExec else { + throw MiniTest.Failure(message: "expected postCreate exec") + } + try MiniTest.expectEqual( + LifecycleUpSupport.execEnv(postCreateExec.arguments)[LifecycleUpSupport.probedVariableName], + LifecycleUpSupport.probedVariableValue, + "postCreate must see probed login-interactive env" + ) + + let code = try ExecCommand.run( + options: ExecOptions(command: ["echo", "ok"], name: resolved.containerName), + runtime: runtime + ) + try MiniTest.expectEqual(code, 0) + let userExec = mock.calls.last { call in + call.arguments.first == "exec" && call.arguments.contains("echo") && call.arguments.contains("ok") + } + guard let userExec else { + throw MiniTest.Failure(message: "expected adevcontainer exec injection") } + try MiniTest.expectEqual( + LifecycleUpSupport.execEnv(userExec.arguments)[LifecycleUpSupport.probedVariableName], + LifecycleUpSupport.probedVariableValue, + "exec must see probed login-interactive env" + ) }), - ("hostRequirementsNoLongerSilentlyIgnored", { - let req = try HostRequirements.parse(["memory": "8gb"] as [String: Any])! - let host = MockHostResourceInfo(physicalMemoryBytes: 1 << 30, cpuCount: 8) - let eval = HostRequirementsEvaluation.evaluate(req, host: host) - try MiniTest.expect(eval.hasHardFailures) - try MiniTest.expect(eval.hardFailures.contains { $0.contains("memory") }) + ("noneSkipsProbe", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "userEnvProbe": "none", + "postCreateCommand": "echo postCreate" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expectEqual(resolved.config.userEnvProbe, .none) + + var probeExecs = 0 + let mock = LifecycleUpSupport.mockFreshCreateThenRunning(resolved: resolved) { args in + if LifecycleUpSupport.isUserEnvProbeExec(args) { + probeExecs += 1 + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(probeExecs, 0, "userEnvProbe none must not probe") + + let code = try ExecCommand.run( + options: ExecOptions(command: ["echo", "ok"], name: resolved.containerName), + runtime: runtime + ) + try MiniTest.expectEqual(code, 0) + try MiniTest.expectEqual(probeExecs, 0, "exec must not probe when userEnvProbe is none") + try MiniTest.expect(mock.calls.contains { + $0.arguments.first == "exec" && LifecycleUpSupport.execBody($0.arguments) == "echo postCreate" + }) + }), + ("probeUsesRemoteConnectionUserNotContainerUser", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "remoteUser": "alice", + "containerUser": "bob", + "postCreateCommand": "echo postCreate" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + + var probeUsers: [String?] = [] + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved, succeedUserEnvProbe: false) { args in + if LifecycleUpSupport.isUserEnvProbeExec(args) { + probeUsers.append(LifecycleUpSupport.execUser(args)) + return ProcessResult( + exitCode: 0, + stdout: Data(LifecycleUpSupport.probedEnvironStdout.utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + _ = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expect(!probeUsers.isEmpty, "probe must run when userEnvProbe is not none") + try MiniTest.expect(probeUsers.allSatisfy { $0 == "alice" }, "probe must use remoteUser alice") + try MiniTest.expect(!probeUsers.contains { $0 == "bob" }, "probe must not use containerUser bob") + }), + ("probeFailureKeepsTheContainer", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postCreateCommand": "echo postCreate" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var sawPostCreate = false + let mock = LifecycleUpSupport.mockFreshCreate(resolved: resolved, succeedUserEnvProbe: false) { args in + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 3, stdout: Data(), stderr: Data("probe-boom\n".utf8)) + } + if LifecycleUpSupport.execBody(args) == "echo postCreate" { + sawPostCreate = true + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + _ = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.property, "userEnvProbe") + try MiniTest.expect(err.message.contains("userEnvProbe")) + } + try MiniTest.expect(!sawPostCreate, "probe failure must block later lifecycle execs") + try MiniTest.expect(!mock.calls.contains { + $0.arguments.first == "delete" && $0.arguments.contains(resolved.containerName) + }, "probe failure must keep the container") + }), + ("execIsNotAttach", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "userEnvProbe": "none", + "postAttachCommand": "exit 99" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expect(resolved.config.postAttachCommand != nil) + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "running", + labels: resolved.labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if LifecycleUpSupport.execBody(args) == "exit 99" { + return ProcessResult(exitCode: 99, stdout: Data(), stderr: Data("attach\n".utf8)) + } + return ProcessResult(exitCode: 0, stdout: Data("ok\n".utf8), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let code = try ExecCommand.run( + options: ExecOptions(command: ["echo", "ok"], name: resolved.containerName), + runtime: runtime + ) + try MiniTest.expectEqual(code, 0) + try MiniTest.expect(!mock.calls.contains { + $0.arguments.first == "exec" && LifecycleUpSupport.execBody($0.arguments) == "exit 99" + }, "exec must not run postAttachCommand") + }), + ("stopContainerConfigStillStopsOnStop", { + for actionJSON in [#" "shutdownAction": "stopContainer" "#, ""] { + let fields = actionJSON.isEmpty + ? #"{ "image": "alpine:3.20" }"# + : """ + { "image": "alpine:3.20", \(actionJSON) } + """ + let ws = try TestRepo.makeTempWorkspace(configJSON: fields) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + if actionJSON.isEmpty { + try MiniTest.expectEqual(resolved.config.shutdownAction, .stopContainer) + } else { + try MiniTest.expectEqual(resolved.config.shutdownAction, .stopContainer) + } + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "running", labels: resolved.labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "stop" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StopCommand.run(name: resolved.containerName, runtime: runtime) + try MiniTest.expect( + mock.calls.contains { $0.arguments == ["stop", resolved.containerName] }, + "explicit stop must stop when shutdownAction is stopContainer or omitted" + ) + } + }), + ("noneDoesNotDisableExplicitStop", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20", "shutdownAction": "none" } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expectEqual(resolved.config.shutdownAction, .none) + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "running", labels: resolved.labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "stop" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StopCommand.run(name: resolved.containerName, runtime: runtime) + try MiniTest.expect( + mock.calls.contains { $0.arguments == ["stop", resolved.containerName] }, + "shutdownAction none must not disable explicit stop" + ) }) ] @@ -2291,6 +3379,9 @@ private enum FeaturesUpTestSupport { return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } if let onExec { return onExec(args) } return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } @@ -2406,6 +3497,163 @@ nonisolated(unsafe) let featuresCommandTests: [(String, () throws -> Void)] = [ } try MiniTest.expect(createArgs.contains("alpine:3.20")) }), + ("upNoFeaturesRunsImageMetadataCreatePathHooks", { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + try MiniTest.expect(resolved.config.features.isEmpty) + let metaJSON = #"[{"onCreateCommand":"echo image-onCreate"},{"updateContentCommand":"echo image-updateContent"},{"postCreateCommand":"echo image-postCreate"},{"postStartCommand":"echo image-postStart"},{"postAttachCommand":"echo image-postAttach"}]"# + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [] as [Any]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["image", "inspect"]) { + return MockProcessRunner.imageInspectHandler( + baseUser: nil, + labels: [DevContainerMetadataLabel.labelKey: metaJSON] + )(args) + } + if args.first == "create" { + return ProcessResult( + exitCode: 0, + stdout: Data("\(resolved.containerName)\n".utf8), + stderr: Data() + ) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "delete" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "build" }) + try MiniTest.expect(execBodies.contains("echo image-onCreate")) + try MiniTest.expect(execBodies.contains("echo image-updateContent")) + try MiniTest.expect(execBodies.contains("echo image-postCreate")) + try MiniTest.expect(execBodies.contains("echo image-postStart")) + try MiniTest.expect(execBodies.contains("echo image-postAttach")) + }), + ("upFinishKeepsBaseImagePostAttachAfterRemelt", { + // Apply already unioned base-image postAttach; finish remelt from a + // features-only image LABEL must not replace it away. + let ref = "ghcr.io/adevcontainer/features/sample-a:1" + let fixture = TestRepo.root() + .appendingPathComponent("Tests/Fixtures/features-sample/sample-a").path + let cache = FileManager.default.temporaryDirectory + .appendingPathComponent("feat-finish-union-\(UUID().uuidString)", isDirectory: true).path + defer { try? FileManager.default.removeItem(atPath: cache) } + let hookDir = (cache as NSString).appendingPathComponent("hook-feature") + try FileManager.default.createDirectory(atPath: hookDir, withIntermediateDirectories: true) + try """ + {"id":"hook-feature","postAttachCommand":"echo feature-attach"} + """.write( + toFile: (hookDir as NSString).appendingPathComponent("devcontainer-feature.json"), + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\n".write( + toFile: (hookDir as NSString).appendingPathComponent("install.sh"), + atomically: true, + encoding: .utf8 + ) + let hookRef = "./.devcontainer/features/hook-feature" + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "features": { "\(hookRef)": {} } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let dest = ws.appendingPathComponent(".devcontainer/features/hook-feature", isDirectory: true) + try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: hookDir, toPath: dest.path) + let restore = FeaturesUpTestSupport.installOverrides( + fetcher: MockFeatureFetcher(packagesByRef: [hookRef: hookDir, ref: fixture]), + cache: cache + ) + defer { restore() } + let baseMeta = #"[{"postAttachCommand":"echo base-attach"}]"# + let featureOnlyMeta = #"[{"postAttachCommand":"echo feature-attach"}]"# + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [] as [Any]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["image", "inspect"]), let inspected = args.last { + let isDerived = inspected.hasPrefix("adev-") || inspected.hasPrefix("adevcontainer:") + let payload = MockProcessRunner.imageInspectJSON( + reference: inspected, + user: "root", + labels: [ + DevContainerMetadataLabel.labelKey: isDerived ? featureOnlyMeta : baseMeta + ] + ) + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["image", "list"]) { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("missing".utf8)) + } + if args.first == "build" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "create" { + return ProcessResult(exitCode: 0, stdout: Data("ctr\n".utf8), stderr: Data()) + } + if args.first == "start" || args.first == "delete" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "exec" { + if LifecycleUpSupport.isUserEnvProbeExec(args) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect( + execBodies.contains("echo base-attach"), + "up finish remelt must keep base-image postAttach that apply already unioned" + ) + try MiniTest.expect(execBodies.contains("echo feature-attach")) + }), ("upReuseRunningNoFeatureFetch", { let ref = "ghcr.io/adevcontainer/features/sample-a:1" let fixture = TestRepo.root() diff --git a/Tests/adevcontainerTests/AllUnitTests.swift b/Tests/adevcontainerTests/AllUnitTests.swift index 1429de9..fa86dcc 100644 --- a/Tests/adevcontainerTests/AllUnitTests.swift +++ b/Tests/adevcontainerTests/AllUnitTests.swift @@ -27,9 +27,16 @@ enum TestRepo { ) return ws } + + static func resolveConfig(_ json: String) throws -> ResolvedDevContainerConfig { + let ws = try makeTempWorkspace(configJSON: json) + defer { try? FileManager.default.removeItem(at: ws) } + return try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]).config + } } final class MockProcessRunner: StreamTeeingProcessRunning, @unchecked Sendable { + private let stateLock = NSLock() var calls: [MockProcessCall] = [] var results: [ProcessResult] = [] var handlers: [([String]) -> ProcessResult?] = [] @@ -63,14 +70,14 @@ final class MockProcessRunner: StreamTeeingProcessRunning, @unchecked Sendable { currentDirectory: String?, stdinData: Data? ) throws -> ProcessResult { - calls.append(MockProcessCall( + recordCall( executable: executable, arguments: arguments, stdinData: stdinData, environment: environment, streamStderr: nil, teeStdoutToStderr: nil - )) + ) return try dispatch(arguments: arguments, stdinData: stdinData) } @@ -83,8 +90,33 @@ final class MockProcessRunner: StreamTeeingProcessRunning, @unchecked Sendable { streamStderr: Bool, teeStdoutToStderr: Bool ) throws -> ProcessResult { - lastStreamStderr = streamStderr - lastTeeStdoutToStderr = teeStdoutToStderr + recordCall( + executable: executable, + arguments: arguments, + stdinData: stdinData, + environment: environment, + streamStderr: streamStderr, + teeStdoutToStderr: teeStdoutToStderr + ) + return try dispatch(arguments: arguments, stdinData: stdinData) + } + + private func recordCall( + executable: String, + arguments: [String], + stdinData: Data?, + environment: [String: String]?, + streamStderr: Bool?, + teeStdoutToStderr: Bool? + ) { + stateLock.lock() + defer { stateLock.unlock() } + if let streamStderr { + lastStreamStderr = streamStderr + } + if let teeStdoutToStderr { + lastTeeStdoutToStderr = teeStdoutToStderr + } calls.append(MockProcessCall( executable: executable, arguments: arguments, @@ -93,7 +125,6 @@ final class MockProcessRunner: StreamTeeingProcessRunning, @unchecked Sendable { streamStderr: streamStderr, teeStdoutToStderr: teeStdoutToStderr )) - return try dispatch(arguments: arguments, stdinData: stdinData) } private func dispatch(arguments: [String], stdinData: Data?) throws -> ProcessResult { @@ -253,6 +284,52 @@ final class MockProcessRunner: StreamTeeingProcessRunning, @unchecked Sendable { } } +final class RecordingHostProcessRunner: ProcessRunning, @unchecked Sendable { + struct Call: Equatable { + var executable: String + var arguments: [String] + var currentDirectory: String? + } + + private let lock = NSLock() + private var storedCalls: [Call] = [] + var exitCode: Int32 = 0 + var handler: ((Call) -> ProcessResult)? + + var calls: [Call] { + lock.lock() + defer { lock.unlock() } + return storedCalls + } + + func run( + executable: String, + arguments: [String], + environment: [String: String]?, + currentDirectory: String?, + stdinData: Data? + ) throws -> ProcessResult { + let call = Call( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory + ) + lock.lock() + storedCalls.append(call) + lock.unlock() + if let handler { + return handler(call) + } + return ProcessResult(exitCode: exitCode, stdout: Data(), stderr: Data()) + } + + static func install(_ runner: RecordingHostProcessRunner) -> () -> Void { + let previous = LifecycleRunner.hostProcessRunnerOverride + LifecycleRunner.hostProcessRunnerOverride = runner + return { LifecycleRunner.hostProcessRunnerOverride = previous } + } +} + // MARK: - Suites nonisolated(unsafe) let errorModelTests: [(String, () throws -> Void)] = [ @@ -915,10 +992,187 @@ nonisolated(unsafe) let admissionTests: [(String, () throws -> Void)] = [ try ConfigAdmissions.admit(raw) }), ("unknownPropertyFails", { - let raw: [String: Any] = ["image": "alpine:3.20", "shutdownAction": "none"] + let raw: [String: Any] = ["image": "alpine:3.20", "notARealProperty": true] try MiniTest.expectThrows({ try ConfigAdmissions.admit(raw) }) { error in + try MiniTest.expectEqual((error as! CLIError).property, "notARealProperty") + } + }), + ("shutdownActionPresenceDoesNotFailParse", { + try ConfigAdmissions.admit([ + "image": "alpine:3.20", + "shutdownAction": "stopContainer" + ]) + try ConfigAdmissions.admit([ + "image": "alpine:3.20", + "shutdownAction": "none" + ]) + let stop = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "shutdownAction": "stopContainer" } + """) + try MiniTest.expectEqual(stop.shutdownAction, .stopContainer) + let none = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "shutdownAction": "none" } + """) + try MiniTest.expectEqual(none.shutdownAction, .none) + }), + ("initializeCommandWaitForUserEnvProbeShutdownActionAdmit", { + try ConfigAdmissions.admit([ + "image": "alpine:3.20", + "initializeCommand": "echo init", + "waitFor": "updateContentCommand", + "userEnvProbe": "loginInteractiveShell", + "shutdownAction": "stopContainer" + ]) + + let full = try TestRepo.resolveConfig(""" + { + "image": "alpine:3.20", + "initializeCommand": "echo init", + "waitFor": "postCreateCommand", + "userEnvProbe": "loginShell", + "shutdownAction": "stopContainer" + } + """) + try MiniTest.expectEqual(full.initializeCommand, .shell("echo init")) + try MiniTest.expectEqual(full.waitFor, .postCreateCommand) + try MiniTest.expectEqual(full.userEnvProbe, .loginShell) + try MiniTest.expectEqual(full.shutdownAction, .stopContainer) + + let argv = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "initializeCommand": ["echo", "init"] } + """) + try MiniTest.expectEqual(argv.initializeCommand, .argv(["echo", "init"])) + + let objectMap = try TestRepo.resolveConfig(""" + { + "image": "alpine:3.20", + "initializeCommand": { + "one": "echo a", + "two": ["echo", "b"] + } + } + """) + guard case .parallel(let named) = objectMap.initializeCommand else { + throw MiniTest.Failure(message: "expected initializeCommand object-map") + } + try MiniTest.expectEqual(named.count, 2) + try MiniTest.expectEqual(named[0].name, "one") + try MiniTest.expectEqual(named[0].command, LifecycleCommand.shell("echo a")) + try MiniTest.expectEqual(named[1].name, "two") + try MiniTest.expectEqual(named[1].command, LifecycleCommand.argv(["echo", "b"])) + + let emptyMap = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "initializeCommand": {} } + """) + try MiniTest.expect(emptyMap.initializeCommand == nil) + + let omitted = try TestRepo.resolveConfig(#"{ "image": "alpine:3.20" }"#) + try MiniTest.expect(omitted.initializeCommand == nil) + try MiniTest.expectEqual(omitted.waitFor, .updateContentCommand) + try MiniTest.expectEqual(omitted.userEnvProbe, .loginInteractiveShell) + try MiniTest.expectEqual(omitted.shutdownAction, .stopContainer) + + for stage in [ + "initializeCommand", + "onCreateCommand", + "updateContentCommand", + "postCreateCommand", + "postStartCommand" + ] { + let resolved = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "waitFor": "\(stage)" } + """) + try MiniTest.expectEqual(resolved.waitFor.rawValue, stage) + } + for probe in ["none", "interactiveShell", "loginShell", "loginInteractiveShell"] { + let resolved = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "userEnvProbe": "\(probe)" } + """) + try MiniTest.expectEqual(resolved.userEnvProbe.rawValue, probe) + } + }), + ("lifecycleRunArgsHostRequirementsPropertySetDoesNotHardError", { + let raw: [String: Any] = [ + "image": "alpine:3.20", + "name": "app", + "initializeCommand": "echo init", + "onCreateCommand": "echo on", + "updateContentCommand": "echo update", + "postCreateCommand": "echo post", + "postStartCommand": "echo start", + "postAttachCommand": "echo attach", + "waitFor": "updateContentCommand", + "userEnvProbe": "loginInteractiveShell", + "shutdownAction": "stopContainer", + "runArgs": ["--init"], + "hostRequirements": ["cpus": 1] as [String: Any] + ] + try ConfigAdmissions.admit(raw) + let resolved = try TestRepo.resolveConfig(""" + { + "image": "alpine:3.20", + "name": "app", + "initializeCommand": "echo init", + "onCreateCommand": "echo on", + "updateContentCommand": "echo update", + "postCreateCommand": "echo post", + "postStartCommand": "echo start", + "postAttachCommand": "echo attach", + "waitFor": "updateContentCommand", + "userEnvProbe": "loginInteractiveShell", + "shutdownAction": "stopContainer", + "runArgs": ["--init"], + "hostRequirements": { "cpus": 1 } + } + """) + try MiniTest.expectEqual(resolved.initializeCommand, .shell("echo init")) + try MiniTest.expectEqual(resolved.runArgs, [.initFlag]) + try MiniTest.expectEqual(resolved.hostRequirements?.cpus, 1.0) + }), + ("shutdownActionStopComposeFailsClosed", { + try ConfigAdmissions.admit([ + "image": "alpine:3.20", + "shutdownAction": "stopCompose" + ]) + try MiniTest.expectThrows({ + _ = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "shutdownAction": "stopCompose" } + """) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.property, "shutdownAction") + try MiniTest.expect(err.message.lowercased().contains("compose")) + } + }), + ("unknownLifecycleValuesFailResolve", { + try MiniTest.expectThrows({ + _ = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "waitFor": "notAStage" } + """) + }) { error in + try MiniTest.expectEqual((error as! CLIError).property, "waitFor") + } + try MiniTest.expectThrows({ + _ = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "userEnvProbe": "notAProbe" } + """) + }) { error in + try MiniTest.expectEqual((error as! CLIError).property, "userEnvProbe") + } + try MiniTest.expectThrows({ + _ = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "shutdownAction": "notAnAction" } + """) + }) { error in try MiniTest.expectEqual((error as! CLIError).property, "shutdownAction") } + try MiniTest.expectThrows({ + _ = try TestRepo.resolveConfig(""" + { "image": "alpine:3.20", "initializeCommand": 42 } + """) + }) { error in + try MiniTest.expectEqual((error as! CLIError).property, "initializeCommand") + } }), ("phaseFixturesAdmit", { let root = TestRepo.root() @@ -1335,6 +1589,26 @@ nonisolated(unsafe) let phase4UnitTests: [(String, () throws -> Void)] = [ base.postAttachCommand = .shell("echo attach") let h3 = ContainerIdentity.configHash(from: base.hashMaterial()) try MiniTest.expectEqual(h1, h3) + + var withInit = base + withInit.initializeCommand = .shell("echo init") + let hInit = ContainerIdentity.configHash(from: withInit.hashMaterial()) + try MiniTest.expect(h1 != hInit) + + var withWait = base + withWait.waitFor = .postCreateCommand + let hWait = ContainerIdentity.configHash(from: withWait.hashMaterial()) + try MiniTest.expect(h1 != hWait) + + var withProbe = base + withProbe.userEnvProbe = .none + let hProbe = ContainerIdentity.configHash(from: withProbe.hashMaterial()) + try MiniTest.expect(h1 != hProbe) + + var withShutdown = base + withShutdown.shutdownAction = .none + let hShutdown = ContainerIdentity.configHash(from: withShutdown.hashMaterial()) + try MiniTest.expectEqual(h1, hShutdown) }) ] @@ -2034,6 +2308,99 @@ nonisolated(unsafe) let phase4UnitTests: [(String, () throws -> Void)] = [ ) try MiniTest.expectEqual(String(data: buffer, encoding: .utf8) ?? "", "") }), + ("lifecycleObjectMapRunsInParallel", { + let secondStarted = DispatchSemaphore(value: 0) + let lock = NSLock() + var firstSawSecond = false + var inFlight = 0 + var maxInFlight = 0 + + let mock = MockProcessRunner() + mock.handlers = [ + { args in + guard args.first == "exec" else { return nil } + lock.lock() + inFlight += 1 + maxInFlight = max(maxInFlight, inFlight) + lock.unlock() + defer { + lock.lock() + inFlight -= 1 + lock.unlock() + } + if args.contains("first-cmd") { + let wait = secondStarted.wait(timeout: .now() + 2) + firstSawSecond = (wait == .success) + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.contains("second-cmd") { + secondStarted.signal() + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let config = ResolvedDevContainerConfig( + image: "alpine:3.20", + workspaceFolder: "/workspaces/app", + onCreateCommand: .parallel([ + NamedLifecycleCommand(name: "alpha", command: .shell("first-cmd")), + NamedLifecycleCommand(name: "beta", command: .shell("second-cmd")) + ]) + ) + try LifecycleRunner.runIfPresent( + property: "onCreateCommand", + command: config.onCreateCommand, + containerId: "ctr-parallel", + config: config, + runtime: runtime, + failurePolicy: .deleteContainerThenFail + ) + try MiniTest.expect(firstSawSecond, "first exec must still be in flight when second starts") + try MiniTest.expect(maxInFlight >= 2, "object-map entries must overlap") + let execs = mock.calls.filter { $0.arguments.first == "exec" } + try MiniTest.expectEqual(execs.count, 2) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + }), + ("lifecycleObjectMapStageFailsIfAnyEntryFails", { + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.first == "exec", args.contains("false") { + return ProcessResult(exitCode: 7, stdout: Data(), stderr: Data("bad-entry\n".utf8)) + } + if args.first == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "delete" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let config = ResolvedDevContainerConfig( + image: "alpine:3.20", + workspaceFolder: "/workspaces/app", + postStartCommand: .parallel([ + NamedLifecycleCommand(name: "ok", command: .shell("true")), + NamedLifecycleCommand(name: "bad", command: .shell("false")) + ]) + ) + try MiniTest.expectThrows({ + try LifecycleRunner.runRestartPostStart( + containerId: "ctr-parallel-fail", + config: config, + runtime: runtime + ) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.lifecycleFailed) + try MiniTest.expect(err.property?.contains("postStartCommand") == true) + } + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + }), ("isBuilderRunningParsesStatusJSON", { let mock = MockProcessRunner() mock.handlers = [ @@ -2911,6 +3278,133 @@ nonisolated(unsafe) let featuresUnitTests: [(String, () throws -> Void)] = [ try MiniTest.expect(ctx.dockerfileContents.contains("_REMOTE_USER=node")) try MiniTest.expect(ctx.dockerfileContents.contains("_CONTAINER_USER=node")) }), + ("featureDockerfileGeneratorBakesPostStartMetadataLabel", { + let meta = FeatureMetadata( + id: "hook-feature", + postStartCommand: .shell("echo baked-postStart"), + postAttachCommand: .shell("echo baked-postAttach") + ) + let ctxDir = FileManager.default.temporaryDirectory + .appendingPathComponent("feat-df-meta-\(UUID().uuidString)", isDirectory: true).path + defer { try? FileManager.default.removeItem(atPath: ctxDir) } + let pkgDir = (ctxDir as NSString).appendingPathComponent("pkg") + try FileManager.default.createDirectory(atPath: pkgDir, withIntermediateDirectories: true) + try #"{"id":"hook-feature"}"#.write( + toFile: (pkgDir as NSString).appendingPathComponent("devcontainer-feature.json"), + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\n".write( + toFile: (pkgDir as NSString).appendingPathComponent("install.sh"), + atomically: true, + encoding: .utf8 + ) + let ordered = [ + FeatureOrder.OrderedFeature( + admitted: AdmittedFeature(reference: "./hook-feature", options: [:]), + metadata: meta + ) + ] + let packages = [ + FetchedFeaturePackage(reference: "./hook-feature", directoryPath: pkgDir) + ] + let ctx = try FeatureDockerfileGenerator.write( + baseImage: "alpine:3.20", + ordered: ordered, + packages: packages, + contextDirectory: ctxDir, + baseUser: "root" + ) + try MiniTest.expect( + ctx.dockerfileContents.contains("LABEL \(DevContainerMetadataLabel.labelKey)="), + "derived image must bake devcontainer.metadata for remelt" + ) + let encoded = DevContainerMetadataLabel.encodeFragments( + try FeatureContributionMerge.collect(from: ordered) + ) + try MiniTest.expect(encoded != nil, "lifecycle fragments must encode") + let parsed = DevContainerMetadataLabel.parseContributions(from: [ + DevContainerMetadataLabel.labelKey: encoded! + ]) + try MiniTest.expectEqual(parsed.postStartCommands, [.shell("echo baked-postStart")]) + try MiniTest.expectEqual(parsed.postAttachCommands, [.shell("echo baked-postAttach")]) + }), + ("featureDockerfileGeneratorBakesUnionedBaseAndFeatureMetadata", { + // Derived LABEL must persist base-image hooks, not overwrite with features-only. + let meta = FeatureMetadata( + id: "hook-feature", + postStartCommand: .shell("echo feature-postStart"), + postAttachCommand: .shell("echo feature-postAttach") + ) + let ctxDir = FileManager.default.temporaryDirectory + .appendingPathComponent("feat-df-union-\(UUID().uuidString)", isDirectory: true).path + defer { try? FileManager.default.removeItem(atPath: ctxDir) } + let pkgDir = (ctxDir as NSString).appendingPathComponent("pkg") + try FileManager.default.createDirectory(atPath: pkgDir, withIntermediateDirectories: true) + try #"{"id":"hook-feature"}"#.write( + toFile: (pkgDir as NSString).appendingPathComponent("devcontainer-feature.json"), + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\n".write( + toFile: (pkgDir as NSString).appendingPathComponent("install.sh"), + atomically: true, + encoding: .utf8 + ) + let ordered = [ + FeatureOrder.OrderedFeature( + admitted: AdmittedFeature(reference: "./hook-feature", options: [:]), + metadata: meta + ) + ] + let packages = [ + FetchedFeaturePackage(reference: "./hook-feature", directoryPath: pkgDir) + ] + var unioned = try FeatureContributionMerge.collect(from: ordered) + unioned.postStartCommands.insert(.shell("echo base-postStart"), at: 0) + unioned.postAttachCommands.insert(.shell("echo base-postAttach"), at: 0) + let ctx = try FeatureDockerfileGenerator.write( + baseImage: "alpine:3.20", + ordered: ordered, + packages: packages, + contextDirectory: ctxDir, + baseUser: "root", + contributions: unioned + ) + try MiniTest.expect( + ctx.dockerfileContents.contains("LABEL \(DevContainerMetadataLabel.labelKey)="), + "derived image must bake unioned devcontainer.metadata" + ) + let encoded = DevContainerMetadataLabel.encodeFragments(unioned) + try MiniTest.expect(encoded != nil, "unioned lifecycle fragments must encode") + try MiniTest.expect( + ctx.dockerfileContents.contains("echo base-postStart"), + "baked LABEL must include base-image postStart" + ) + try MiniTest.expect( + ctx.dockerfileContents.contains("echo base-postAttach"), + "baked LABEL must include base-image postAttach" + ) + try MiniTest.expect( + ctx.dockerfileContents.contains("echo feature-postStart"), + "baked LABEL must include feature postStart" + ) + try MiniTest.expect( + ctx.dockerfileContents.contains("echo feature-postAttach"), + "baked LABEL must include feature postAttach" + ) + let parsed = DevContainerMetadataLabel.parseContributions(from: [ + DevContainerMetadataLabel.labelKey: encoded! + ]) + try MiniTest.expectEqual( + parsed.postStartCommands, + [.shell("echo base-postStart"), .shell("echo feature-postStart")] + ) + try MiniTest.expectEqual( + parsed.postAttachCommands, + [.shell("echo base-postAttach"), .shell("echo feature-postAttach")] + ) + }), ("featureDockerfileGeneratorInstallSeesContainerEnv", { // Feature metadata containerEnv (e.g. DOTNET_ROOT) must reach install.sh. var meta = try FeatureMetadata.parse( @@ -3139,7 +3633,7 @@ nonisolated(unsafe) let featuresUnitTests: [(String, () throws -> Void)] = [ try MiniTest.expect(!mockProc.calls.contains { $0.arguments.first == "build" }) }), ("derivedImageTagRecipeVersionBumpedForUserRestore", { - try MiniTest.expectEqual(DerivedImageTag.recipeVersion, "5") + try MiniTest.expectEqual(DerivedImageTag.recipeVersion, "6") let metaA = try FeatureMetadata.parse( data: try Data(contentsOf: URL(fileURLWithPath: FeaturesTestSupport.fixtureFeatureDir("sample-a")) .appendingPathComponent("devcontainer-feature.json")), @@ -3151,25 +3645,25 @@ nonisolated(unsafe) let featuresUnitTests: [(String, () throws -> Void)] = [ metadata: metaA ) ] - let v4 = DerivedImageTag.compute( + let v5 = DerivedImageTag.compute( baseImage: "alpine:3.20", ordered: ordered, nameBase: "x", - recipeVersion: "4" + recipeVersion: "5" ) - let v5 = DerivedImageTag.compute( + let v6 = DerivedImageTag.compute( baseImage: "alpine:3.20", ordered: ordered, nameBase: "x", - recipeVersion: "5" + recipeVersion: "6" ) - try MiniTest.expect(v4 != v5, "recipeVersion bump must change derived tag") + try MiniTest.expect(v5 != v6, "recipeVersion bump must change derived tag") let product = DerivedImageTag.compute( baseImage: "alpine:3.20", ordered: ordered, nameBase: "x" ) - try MiniTest.expectEqual(product, v5) + try MiniTest.expectEqual(product, v6) }), ("tomlMergeBuildRosettaFalsePreservesKeys", { let input = """ @@ -3704,6 +4198,71 @@ nonisolated(unsafe) let featuresUnitTests: [(String, () throws -> Void)] = [ try MiniTest.expect(result.reusedExistingImage) try MiniTest.expect(!mockProc.calls.contains { $0.arguments.first == "build" }) }), + ("featuresRunnerBakesUnionedBaseImageMetadataLabel", { + let cache = FileManager.default.temporaryDirectory + .appendingPathComponent("feat-bake-union-\(UUID().uuidString)", isDirectory: true).path + defer { try? FileManager.default.removeItem(atPath: cache) } + let pkgDir = (cache as NSString).appendingPathComponent("hook-pkg") + try FileManager.default.createDirectory(atPath: pkgDir, withIntermediateDirectories: true) + try """ + {"id":"hook-feature","postStartCommand":"echo feature-postStart","postAttachCommand":"echo feature-postAttach"} + """.write( + toFile: (pkgDir as NSString).appendingPathComponent("devcontainer-feature.json"), + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\n".write( + toFile: (pkgDir as NSString).appendingPathComponent("install.sh"), + atomically: true, + encoding: .utf8 + ) + let mockFetch = MockFeatureFetcher(packagesByRef: ["./hook-feature": pkgDir]) + let mockProc = MockProcessRunner() + let baseLabels = [ + DevContainerMetadataLabel.labelKey: + #"[{"postStartCommand":"echo base-postStart"},{"postAttachCommand":"echo base-postAttach"}]"# + ] + mockProc.handlers = [ + MockProcessRunner.imageInspectHandler(baseUser: "root", labels: baseLabels), + { args in + if args.starts(with: ["image", "list"]) { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("missing".utf8)) + } + if args.first == "build" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mockProc) + _ = try FeaturesRunner.run( + features: [AdmittedFeature(reference: "./hook-feature", options: [:])], + baseImage: "alpine:3.20", + deps: FeaturesRunner.Dependencies( + fetcher: mockFetch, + runtime: runtime, + cacheRoot: cache, + platform: "linux/arm64" + ) + ) + guard let buildCall = mockProc.calls.first(where: { $0.arguments.first == "build" }), + let fIdx = buildCall.arguments.firstIndex(of: "-f"), + fIdx + 1 < buildCall.arguments.count + else { + throw MiniTest.Failure(message: "expected Features build Dockerfile") + } + let contents = try String(contentsOfFile: buildCall.arguments[fIdx + 1], encoding: .utf8) + try MiniTest.expect( + contents.contains("echo base-postStart"), + "derived LABEL must include base-image postStart after Features build" + ) + try MiniTest.expect( + contents.contains("echo base-postAttach"), + "derived LABEL must include base-image postAttach after Features build" + ) + try MiniTest.expect(contents.contains("echo feature-postStart")) + try MiniTest.expect(contents.contains("echo feature-postAttach")) + }), ("featureInstallerCpAndExecAsRoot", { // Helper retained; up path uses build. Still cover cp/exec mechanics. let mockProc = MockProcessRunner() diff --git a/Tests/adevcontainerTests/CloneInVolumeTests.swift b/Tests/adevcontainerTests/CloneInVolumeTests.swift index d92209c..61597a4 100644 --- a/Tests/adevcontainerTests/CloneInVolumeTests.swift +++ b/Tests/adevcontainerTests/CloneInVolumeTests.swift @@ -1298,6 +1298,53 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(mock.calls.contains { $0.arguments.first == "delete" }) try MiniTest.expect(mock.calls.contains { $0.arguments.starts(with: ["volume", "delete"]) }) }), + ("cloneRunsInitializeCommandOnHostCheckout", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let git = MockGitClient() + git.configJSONToWrite = """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-clone" + } + """ + var events: [String] = [] + let host = RecordingHostProcessRunner() + host.handler = { _ in + events.append("initialize") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let mock = MockProcessRunner() + mock.handlers = CloneRuntimeMock.handlers(onCreate: { args in + if args.first == "create" { + events.append("create") + } + }) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try CloneCommand.run( + options: CloneOptions(gitURL: "https://github.com/org/init-clone.git", skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect(events.contains("initialize")) + try MiniTest.expect(events.contains("create")) + try MiniTest.expectEqual(events.first, "initialize") + let initIdx = events.firstIndex(of: "initialize")! + let createIdx = events.firstIndex(of: "create")! + try MiniTest.expect(initIdx < createIdx, "initializeCommand must run before create") + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(host.calls[0].arguments.contains("echo init-clone")) + let checkout = git.fetchConfigCalls[0].directory + try MiniTest.expectEqual( + (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (checkout as NSString).standardizingPath + ) + }), ("cloneHookFailureDeletesContainer", { let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } @@ -1323,6 +1370,116 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ ) } try MiniTest.expect(mock.calls.contains { $0.arguments.first == "delete" }) + try MiniTest.expect(mock.calls.contains { $0.arguments.starts(with: ["volume", "delete"]) }) + }), + ("cloneRunsPostAttachWithoutVSCode", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let git = MockGitClient() + git.configJSONToWrite = """ + { + "image": "alpine:3.20", + "postCreateCommand": "echo postCreate", + "postAttachCommand": "echo clone-attach" + } + """ + var hookBodies: [String] = [] + let mock = MockProcessRunner() + let baseHandlers = CloneRuntimeMock.handlers() + mock.handlers = [ + { args in + if args.first == "exec", + let lc = args.firstIndex(of: "-lc"), + lc + 1 < args.count + { + hookBodies.append(args[lc + 1]) + } + for h in baseHandlers { + if let r = h(args) { return r } + } + return nil + } + ] + let launcher = MockVSCodeLauncher() + let prevLauncher = VSCodeOpen.launcherOverride + let prevResolver = VSCodeOpen.resolverOverride + VSCodeOpen.launcherOverride = launcher + VSCodeOpen.resolverOverride = MockVSCodeResolver(path: "/opt/code") + defer { + VSCodeOpen.launcherOverride = prevLauncher + VSCodeOpen.resolverOverride = prevResolver + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try CloneCommand.run( + options: CloneOptions( + gitURL: "https://github.com/org/clone-pa-novsc.git", + skipPull: true, + openVSCode: false + ), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(launcher.calls.count, 0) + try MiniTest.expect(hookBodies.contains("echo clone-attach")) + try MiniTest.expect(hookBodies.contains("echo postCreate")) + try MiniTest.expect(!FileManager.default.fileExists(atPath: git.fetchConfigCalls[0].directory)) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + }), + ("clonePostAttachRunsWhenOpenSoftFails", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let git = MockGitClient() + git.configJSONToWrite = """ + { + "image": "alpine:3.20", + "postAttachCommand": "echo clone-attach" + } + """ + var hookBodies: [String] = [] + let mock = MockProcessRunner() + let baseHandlers = CloneRuntimeMock.handlers() + mock.handlers = [ + { args in + if args.first == "exec", + let lc = args.firstIndex(of: "-lc"), + lc + 1 < args.count + { + hookBodies.append(args[lc + 1]) + } + for h in baseHandlers { + if let r = h(args) { return r } + } + return nil + } + ] + let launcher = MockVSCodeLauncher() + let prevLauncher = VSCodeOpen.launcherOverride + let prevResolver = VSCodeOpen.resolverOverride + VSCodeOpen.launcherOverride = launcher + VSCodeOpen.resolverOverride = MockVSCodeResolver(path: nil) + defer { + VSCodeOpen.launcherOverride = prevLauncher + VSCodeOpen.resolverOverride = prevResolver + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try CloneCommand.run( + options: CloneOptions( + gitURL: "https://github.com/org/clone-pa-soft-vol.git", + skipPull: true, + openVSCode: true + ), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect(hookBodies.contains("echo clone-attach")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + try MiniTest.expect(!FileManager.default.fileExists(atPath: git.fetchConfigCalls[0].directory)) }), ("cloneInjectsGitFeatureWhenConfigHasNone", { let restore = CloneGitFeatureTestSupport.installOverrides() @@ -1835,16 +1992,32 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ let labels = rows.first?["labels"] as? [String: String] try MiniTest.expectEqual(labels?[RecoveryHelper.recoveryMarkerLabel], RecoveryHelper.recoveryMarkerValue) }), - ("startStoppedManagedNoHooks", { - let mock = MockProcessRunner() + ("startStoppedManagedRunsPostStart", { + let configJSON = """ + { + "image": "alpine:3.20", + "onCreateCommand": "echo onCreate", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo config-postStart" + } + """ + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, + ContainerIdentity.labelLocalFolder: "volume://adev-app-ws", + ContainerIdentity.labelConfigFile: ".devcontainer/devcontainer.json", + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app", + DevContainerMetadataLabel.labelKey: #"{"postStartCommand":"echo feature-postStart"}"# + ] let entry = MockProcessRunner.containerListJSON( id: "adev-app-aaaabbbbcccc", state: "stopped", - labels: [ - ContainerIdentity.labelManaged: ContainerIdentity.managedValue, - ContainerIdentity.labelWorkspaceMode: "volume" - ] + labels: labels, + image: "alpine:3.20" ) + var execBodies: [String] = [] + let mock = MockProcessRunner() mock.handlers = [ { args in if args.starts(with: ["list"]) { @@ -1854,6 +2027,19 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ if args.first == "start" { return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if args.contains("cat") && !args.contains(LifecycleRunner.userEnvProbeScript) { + return ProcessResult(exitCode: 0, stdout: Data(configJSON.utf8), stderr: Data()) + } + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return nil } ] @@ -1863,22 +2049,193 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ runtime: runtime ) try MiniTest.expect(mock.calls.contains { $0.arguments == ["start", "adev-app-aaaabbbbcccc"] }) - try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "exec" }) + try MiniTest.expectEqual(execBodies, ["echo config-postStart", "echo feature-postStart"]) + try MiniTest.expect(!execBodies.contains("echo onCreate")) + try MiniTest.expect(!execBodies.contains("echo updateContent")) + try MiniTest.expect(!execBodies.contains("echo postCreate")) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) }), - ("startAlreadyRunningNoOp", { + ("volumeModeStartWithoutHostWorkspaceSkipsInitializeCommand", { + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let previous = StatusPrinter.onWarning + var warnings: [String] = [] + StatusPrinter.onWarning = { warnings.append($0) } + defer { StatusPrinter.onWarning = previous } + let configJSON = """ + { "image": "alpine:3.20", "initializeCommand": "echo init-volume" } + """ + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, + ContainerIdentity.labelLocalFolder: "volume://adev-app-ws", + ContainerIdentity.labelConfigFile: ".devcontainer/devcontainer.json", + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels + ) let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec", args.contains("cat") { + return ProcessResult(exitCode: 0, stdout: Data(configJSON.utf8), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(host.calls.isEmpty, "volume-mode start must not run host initialize") + try MiniTest.expect( + warnings.contains { $0.lowercased().contains("initializecommand") && $0.lowercased().contains("host") }, + "must warn that the host command cannot run" + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + }), + ("volumeModeStartDoesNotInitializeAfterStartWhenGuestConfigBecomesReadable", { + // Clone-origin: usable host checkout exists, but guest config is only + // readable after start. Initialize after start would violate + // initialize-before-start / failure-must-not-start. + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20" } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let configJSON = """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-after-start", + "postStartCommand": "echo config-postStart" + } + """ + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ".devcontainer/devcontainer.json", + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + var started = false + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + started = true + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if args.contains("cat") && !args.contains(LifecycleRunner.userEnvProbeScript) { + if !started { + return ProcessResult( + exitCode: 1, + stdout: Data(), + stderr: Data("container not running".utf8) + ) + } + return ProcessResult(exitCode: 0, stdout: Data(configJSON.utf8), stderr: Data()) + } + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect( + host.calls.isEmpty, + "must not run initialize after start when guest config becomes readable" + ) + try MiniTest.expect( + execBodies.contains("echo config-postStart"), + "after-start remelt/postStart must still run" + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + }), + ("startAlreadyRunningNoOp", { + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-already-running", + "postStartCommand": "echo postStart-already-running" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] let entry = MockProcessRunner.containerListJSON( id: "adev-app-aaaabbbbcccc", state: "running", - labels: [ContainerIdentity.labelManaged: ContainerIdentity.managedValue] + labels: labels ) + var execBodies: [String] = [] + let mock = MockProcessRunner() mock.handlers = [ { args in if args.starts(with: ["list"]) { let data = try! JSONSerialization.data(withJSONObject: [entry]) return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return nil } ] @@ -1888,6 +2245,11 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ runtime: runtime ) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect( + !execBodies.contains("echo postStart-already-running"), + "already-running start must not run postStart" + ) }), ("startInteractivePickerWhenMultiple", { let mock = MockProcessRunner() diff --git a/Tests/adevcontainerTests/ConfigReaderTests.swift b/Tests/adevcontainerTests/ConfigReaderTests.swift index a30afe0..cccc7a5 100644 --- a/Tests/adevcontainerTests/ConfigReaderTests.swift +++ b/Tests/adevcontainerTests/ConfigReaderTests.swift @@ -507,5 +507,168 @@ nonisolated(unsafe) let configReaderTests: [(String, () throws -> Void)] = [ try MiniTest.expect(config != nil, "loader resolves with metadata label") try MiniTest.expectEqual(config?.featurePostAttachCommands.count, 1, "metadata postAttach merged") try MiniTest.expectEqual(config?.featurePostAttachCommands.first?.execArguments, ["sh", "-lc", "echo meta-attach"]) + }), + + ("loaderParityMergeFeaturePostStartFromLabel", { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20"}"#) + let configFile = ws.appendingPathComponent(".devcontainer/devcontainer.json").path + var labels = bindLabels(localFolder: ws.path, configFile: configFile) + labels[DevContainerMetadataLabel.labelKey] = #"{"postStartCommand":"echo meta-postStart","postAttachCommand":"echo meta-attach"}"# + let config = try PostAttachConfigLoader.load( + labels: labels, + containerId: "c1", + imageRef: nil, + runtime: mockRuntime(MockProcessRunner()) + ) + try MiniTest.expect(config != nil, "loader resolves with metadata label") + try MiniTest.expectEqual(config?.featurePostStartCommands.count, 1, "metadata postStart merged") + try MiniTest.expectEqual( + config?.featurePostStartCommands.first?.execArguments, + ["sh", "-lc", "echo meta-postStart"] + ) + try MiniTest.expectEqual(config?.featurePostAttachCommands.count, 1, "metadata postAttach still merged") + }), + + ("loaderParityMergeFeaturePostStartFromImageInspect", { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20"}"#) + let configFile = ws.appendingPathComponent(".devcontainer/devcontainer.json").path + let labels = bindLabels(localFolder: ws.path, configFile: configFile) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [ + DevContainerMetadataLabel.labelKey: + #"{"postStartCommand":"echo image-postStart"}"# + ] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + return nil + } + ] + let config = try PostAttachConfigLoader.load( + labels: labels, + containerId: "c1", + imageRef: "alpine:3.20", + runtime: mockRuntime(mock) + ) + try MiniTest.expectEqual(config?.featurePostStartCommands.count, 1, "image metadata postStart merged") + try MiniTest.expectEqual( + config?.featurePostStartCommands.first?.execArguments, + ["sh", "-lc", "echo image-postStart"] + ) + }), + + ("loaderParityMergeUnionsImagePostStartWhenContainerMetadataLacksIt", { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20"}"#) + let configFile = ws.appendingPathComponent(".devcontainer/devcontainer.json").path + var labels = bindLabels(localFolder: ws.path, configFile: configFile) + // Container inherited base metadata (remoteUser only) — remelt must still + // inspect the image for feature postStart instead of treating the key as complete. + labels[DevContainerMetadataLabel.labelKey] = #"{"remoteUser":"vscode"}"# + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [ + DevContainerMetadataLabel.labelKey: + #"{"postStartCommand":"echo image-union-postStart"}"# + ] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + return nil + } + ] + let config = try PostAttachConfigLoader.load( + labels: labels, + containerId: "c1", + imageRef: "alpine:3.20", + runtime: mockRuntime(mock) + ) + try MiniTest.expectEqual( + config?.featurePostStartCommands.first?.execArguments, + ["sh", "-lc", "echo image-union-postStart"] + ) + }), + + ("loaderRemeltsMetadataWhenConfigUnreadable", { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20"}"#) + let missing = ws.appendingPathComponent(".devcontainer/absent.json").path + let labels = bindLabels(localFolder: ws.path, configFile: missing) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [ + DevContainerMetadataLabel.labelKey: + #"{"postStartCommand":"echo meta-only-postStart","postAttachCommand":"echo meta-only-postAttach"}"# + ] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + return nil + } + ] + let config = try PostAttachConfigLoader.load( + labels: labels, + containerId: "c1", + imageRef: "alpine:3.20", + runtime: mockRuntime(mock) + ) + try MiniTest.expect(config != nil, "unreadable config must still remelt image metadata") + try MiniTest.expectEqual( + config?.featurePostStartCommands.first?.execArguments, + ["sh", "-lc", "echo meta-only-postStart"] + ) + try MiniTest.expectEqual( + config?.featurePostAttachCommands.first?.execArguments, + ["sh", "-lc", "echo meta-only-postAttach"] + ) + try MiniTest.expect(config?.postStartCommand == nil, "metadata-only stub has no config postStart") + try MiniTest.expect(config?.initializeCommand == nil, "initialize stays host/config-only") + try MiniTest.expectEqual(config?.userEnvProbe, UserEnvProbe.none, "unreadable config must not invent a probe") + try MiniTest.expect(!config!.hasApplyableVscodeCustomizations, "start must not apply vscode from stub") + }), + + ("mergeFeaturePostAttachUnionsExistingApplyHooks", { + var config = ResolvedDevContainerConfig( + image: "alpine:3.20", + workspaceFolder: "/workspaces/app", + featurePostAttachCommands: [.shell("echo base-attach")] + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [ + DevContainerMetadataLabel.labelKey: + #"{"postAttachCommand":"echo feature-only-attach"}"# + ] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + return nil + } + ] + PostAttachConfigLoader.mergeFeaturePostAttach( + into: &config, + imageRef: "alpine:3.20", + runtime: mockRuntime(mock) + ) + try MiniTest.expectEqual( + config.featurePostAttachCommands, + [.shell("echo base-attach"), .shell("echo feature-only-attach")], + "finish remelt must not replace-away apply-unioned base-image postAttach" + ) }) ] diff --git a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift index 58deefa..e0c9822 100644 --- a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift +++ b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift @@ -45,6 +45,10 @@ final class RebuildScenario { var newContainerId = "new-id-created" /// exec script substrings that should exit non-zero. var failingExecSubstrings: [String] = [] + /// Volume-mode guest `.devcontainer/` presence for initialize staging. + var guestDevcontainerExists = true + /// `tar cf - -C .devcontainer` stdout returned by the mock exec. + var guestDevcontainerTar: Data? var runtime: AppleContainerRuntime { AppleContainerRuntime(executablePath: "container", runner: mock) @@ -168,6 +172,15 @@ final class RebuildScenario { if args.contains("cat") { return ProcessResult(exitCode: 0, stdout: Data(volumeConfigText.utf8), stderr: Data()) } + if args.contains("test"), args.contains("-d") { + let path = args.last ?? "" + if path.hasSuffix(".devcontainer") { + return guestDevcontainerExists ? ok(Data()) : fail("missing") + } + } + if args.contains("tar"), args.contains("cf") { + return ProcessResult(exitCode: 0, stdout: guestDevcontainerTar ?? Data(), stderr: Data()) + } if let script = args.last, failingExecSubstrings.contains(where: { script.contains($0) }) { return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("script failed".utf8)) @@ -281,6 +294,33 @@ func withRebuildFeatureOverrides( try body() } +/// Host-side `tar cf -` of a `.devcontainer/` tree for volume-mode initialize tests. +func makeGuestDevcontainerArchive(files: [String: String]) throws -> Data { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("adev-init-tar-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let dc = root.appendingPathComponent(".devcontainer", isDirectory: true) + try FileManager.default.createDirectory(at: dc, withIntermediateDirectories: true) + for (relative, contents) in files { + let url = dc.appendingPathComponent(relative) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + let result = try FoundationProcessRunner().run( + executable: "/usr/bin/tar", + arguments: ["cf", "-", "-C", root.path, ".devcontainer"], + environment: nil, + currentDirectory: nil + ) + guard result.succeeded, !result.stdout.isEmpty else { + throw MiniTest.Failure(message: "failed to build guest .devcontainer archive") + } + return result.stdout +} + /// Feature overrides for volume-mode scenarios: volume mode injects the git feature, /// so map the git ref to the sample fixture and no-op the native-arm rosetta probe. func withRebuildVolumeOverrides(_ body: () throws -> Void) throws { @@ -1491,6 +1531,349 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ try MiniTest.expect(firstExec > createIdx, "hooks run on the NEW container") }), + ("rebuildRunsHostInitializeBeforeCreate", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-rebuild", + "onCreateCommand": "echo onCreateBoom", + "updateContentCommand": "echo updateContentCustom", + "postCreateCommand": "echo postCreateCustom", + "postStartCommand": "echo postStartCustom" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + var events: [String] = [] + let host = RecordingHostProcessRunner() + host.handler = { _ in + events.append("initialize") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let s = RebuildScenario() + let info = RebuildScenario.container( + id: "old-id", + labels: s.bindLabels( + localFolder: ws.path, + configFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path + ) + ) + s.containers = [info] + s.failingExecSubstrings = ["onCreateBoom"] + s.install() + defer { try? BindRecoveryResume.cleanup(name: info.name) } + try MiniTest.expectThrows({ + _ = try RebuildCommand.run( + options: RebuildOptions(skipPull: true, jsonOutput: true), + runtime: s.runtime, + isTTY: false + ) + }) { err in + let cli = err as? CLIError + try MiniTest.expect( + cli?.property?.contains("onCreateCommand") == true + || cli?.code == CLIErrorCode.recoveryUnavailable, + "create-path hook failure (or bind recovery of that failure)" + ) + } + let createIdx = s.mock.calls.firstIndex { $0.arguments.first == "create" } + try MiniTest.expect(createIdx != nil, "new container is created after host initialize") + try MiniTest.expectEqual(events.first, "initialize") + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(host.calls[0].arguments.contains("echo init-rebuild")) + try MiniTest.expectEqual( + (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (ws.path as NSString).standardizingPath + ) + try MiniTest.expect( + s.mock.calls.contains { $0.arguments.first == "delete" && $0.arguments.last == s.newContainerId }, + "first create-path hook failure deletes only the new container" + ) + }), + + ("volumeModeRebuildWithoutHostWorkspaceStillRunsInitializeCommand", { + let s = RebuildScenario() + let host = RecordingHostProcessRunner() + var initCwd: String? + var sawDeleteOrCreateDuringInit = false + var stagedHasDevcontainer = false + var stagedHasSetup = false + var stagedHasScripts = false + host.handler = { call in + initCwd = call.currentDirectory + if s.mock.calls.contains(where: { + $0.arguments.first == "delete" || $0.arguments.first == "create" + }) { + sawDeleteOrCreateDuringInit = true + } + let cwd = call.currentDirectory ?? "" + let dc = (cwd as NSString).appendingPathComponent(".devcontainer") + var isDir: ObjCBool = false + stagedHasDevcontainer = FileManager.default.fileExists(atPath: dc, isDirectory: &isDir) && isDir.boolValue + stagedHasSetup = FileManager.default.fileExists( + atPath: (dc as NSString).appendingPathComponent("setup.sh") + ) + stagedHasScripts = FileManager.default.fileExists( + atPath: (cwd as NSString).appendingPathComponent("scripts") + ) + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + var labels = s.volumeLabels() + labels[ContainerIdentity.labelLocalFolder] = "volume://adev-repo-ws" + let info = RebuildScenario.container(id: "vol-old", labels: labels) + s.containers = [info] + s.volumes = ["adev-repo-ws"] + s.volumeConfigText = """ + { + "image": "alpine:3.20", + "initializeCommand": "bash .devcontainer/setup.sh" + } + """ + s.guestDevcontainerTar = try makeGuestDevcontainerArchive(files: [ + "devcontainer.json": s.volumeConfigText, + "setup.sh": "#!/bin/sh\necho staged\n" + ]) + s.install() + try withRebuildVolumeOverrides { + _ = try RebuildCommand.run( + options: RebuildOptions(skipPull: true), + runtime: s.runtime + ) + } + try MiniTest.expectEqual(host.calls.count, 1, "initializeCommand must run on the host") + try MiniTest.expect(host.calls[0].arguments.contains("bash .devcontainer/setup.sh")) + try MiniTest.expect(stagedHasDevcontainer, "temp cwd must contain guest .devcontainer/") + try MiniTest.expect(stagedHasSetup, "bash .devcontainer/setup.sh must resolve from the temp cwd") + try MiniTest.expect(!stagedHasScripts, "temp root is not a full guest workspace checkout") + try MiniTest.expect(!sawDeleteOrCreateDuringInit, "initialize runs before old delete / new create") + try MiniTest.expect( + s.mock.calls.contains { $0.arguments.first == "create" }, + "new container is created only after initialize succeeds" + ) + try MiniTest.expect( + s.mock.calls.contains { $0.arguments.first == "delete" && $0.arguments.last == "vol-old" }, + "old container is deleted after initialize" + ) + guard let cwd = initCwd else { + throw MiniTest.Failure(message: "initialize cwd was not observed") + } + try MiniTest.expect( + !FileManager.default.fileExists(atPath: cwd), + "temporary workspace root is removed after the hook" + ) + try MiniTest.expect( + (cwd as NSString).standardizingPath != (s.volumes[0] as NSString).standardizingPath + ) + }), + + ("volumeModeRebuildInitializeTempIsRemovedAfterFailure", { + let host = RecordingHostProcessRunner() + var initCwd: String? + host.handler = { call in + initCwd = call.currentDirectory + return ProcessResult(exitCode: 9, stdout: Data(), stderr: Data("init failed".utf8)) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let s = RebuildScenario() + var labels = s.volumeLabels() + labels[ContainerIdentity.labelLocalFolder] = "volume://adev-repo-ws" + let info = RebuildScenario.container(id: "vol-old", labels: labels) + s.containers = [info] + s.volumes = ["adev-repo-ws"] + s.volumeConfigText = """ + { + "image": "alpine:3.20", + "initializeCommand": "exit 9" + } + """ + s.guestDevcontainerTar = try makeGuestDevcontainerArchive(files: [ + "devcontainer.json": s.volumeConfigText + ]) + s.install() + try MiniTest.expectThrows({ + try withRebuildVolumeOverrides { + _ = try RebuildCommand.run( + options: RebuildOptions(skipPull: true), + runtime: s.runtime + ) + } + }) { err in + let cli = err as? CLIError + try MiniTest.expectEqual(cli?.property, "initializeCommand") + try MiniTest.expectEqual(cli?.code, CLIErrorCode.lifecycleFailed) + } + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(!s.mock.calls.contains { $0.arguments.first == "create" }, "must not create the new container") + try MiniTest.expect( + !s.mock.calls.contains { $0.arguments.first == "delete" }, + "old container remains" + ) + guard let cwd = initCwd else { + throw MiniTest.Failure(message: "initialize cwd was not observed") + } + try MiniTest.expect( + !FileManager.default.fileExists(atPath: cwd), + "temporary workspace root is removed after initialize failure" + ) + }), + + ("missingDevcontainerDirectoryDoesNotSkipInitializeCommandOnVolumeRebuild", { + let host = RecordingHostProcessRunner() + var initCwd: String? + let previous = StatusPrinter.onWarning + var warnings: [String] = [] + StatusPrinter.onWarning = { warnings.append($0) } + defer { StatusPrinter.onWarning = previous } + var stagedHasRootJson = false + var stagedHasDevcontainerDir = false + host.handler = { call in + initCwd = call.currentDirectory + let cwd = call.currentDirectory ?? "" + stagedHasRootJson = FileManager.default.fileExists( + atPath: (cwd as NSString).appendingPathComponent(".devcontainer.json") + ) + stagedHasDevcontainerDir = FileManager.default.fileExists( + atPath: (cwd as NSString).appendingPathComponent(".devcontainer") + ) + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let s = RebuildScenario() + var labels = s.volumeLabels(configFile: ".devcontainer.json") + labels[ContainerIdentity.labelLocalFolder] = "volume://adev-repo-ws" + let info = RebuildScenario.container(id: "vol-old", labels: labels) + s.containers = [info] + s.volumes = ["adev-repo-ws"] + s.guestDevcontainerExists = false + s.volumeConfigText = """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-global" + } + """ + s.install() + try withRebuildVolumeOverrides { + _ = try RebuildCommand.run( + options: RebuildOptions(skipPull: true), + runtime: s.runtime + ) + } + try MiniTest.expectEqual(host.calls.count, 1, "missing .devcontainer/ must not skip initializeCommand") + try MiniTest.expect(host.calls[0].arguments.contains("echo init-global")) + try MiniTest.expect(stagedHasRootJson, "temp cwd must contain the root .devcontainer.json") + try MiniTest.expect(!stagedHasDevcontainerDir, "missing guest .devcontainer/ must not be invented") + try MiniTest.expect( + !warnings.contains { $0.lowercased().contains("initializecommand") && $0.lowercased().contains("host") }, + "must not skip+warn solely because .devcontainer/ is absent" + ) + guard let cwd = initCwd else { + throw MiniTest.Failure(message: "initialize cwd was not observed") + } + try MiniTest.expect( + !FileManager.default.fileExists(atPath: cwd), + "temporary workspace root is removed after the hook" + ) + }), + + ("volumeModeRebuildInitializeIsNotAFullWorkspaceCheckout", { + let host = RecordingHostProcessRunner() + var stagedHasNestedConfig = false + var stagedHasScripts = false + host.handler = { call in + let cwd = call.currentDirectory ?? "" + stagedHasNestedConfig = FileManager.default.fileExists( + atPath: (cwd as NSString).appendingPathComponent(".devcontainer/devcontainer.json") + ) + stagedHasScripts = FileManager.default.fileExists( + atPath: (cwd as NSString).appendingPathComponent("scripts/bootstrap.sh") + ) + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let s = RebuildScenario() + var labels = s.volumeLabels() + labels[ContainerIdentity.labelLocalFolder] = "volume://adev-repo-ws" + let info = RebuildScenario.container(id: "vol-old", labels: labels) + s.containers = [info] + s.volumes = ["adev-repo-ws"] + s.volumeConfigText = """ + { + "image": "alpine:3.20", + "initializeCommand": "echo only-devcontainer" + } + """ + s.guestDevcontainerTar = try makeGuestDevcontainerArchive(files: [ + "devcontainer.json": s.volumeConfigText + ]) + s.install() + try withRebuildVolumeOverrides { + _ = try RebuildCommand.run( + options: RebuildOptions(skipPull: true), + runtime: s.runtime + ) + } + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(stagedHasNestedConfig, "temp root contains guest .devcontainer/") + try MiniTest.expect( + !stagedHasScripts, + "success must not depend on other guest paths such as ./scripts/" + ) + }), + + ("volumeModeRebuildWithRetainedHostCheckoutUsesThatPath", { + let checkout = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-retained" + } + """) + defer { try? FileManager.default.removeItem(at: checkout) } + let host = RecordingHostProcessRunner() + host.handler = { _ in + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let s = RebuildScenario() + var labels = s.volumeLabels() + labels[ContainerIdentity.labelLocalFolder] = checkout.path + let info = RebuildScenario.container(id: "vol-old", labels: labels) + s.containers = [info] + s.volumes = ["adev-repo-ws"] + s.volumeConfigText = """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-retained" + } + """ + s.install() + try withRebuildVolumeOverrides { + _ = try RebuildCommand.run( + options: RebuildOptions(skipPull: true), + runtime: s.runtime + ) + } + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expectEqual( + (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (checkout.path as NSString).standardizingPath + ) + try MiniTest.expect( + !s.mock.calls.contains { $0.arguments.contains("tar") && $0.arguments.contains("cf") }, + "must not substitute a temporary workspace root when a retained checkout exists" + ) + try MiniTest.expect( + FileManager.default.fileExists(atPath: checkout.path), + "retained host checkout is durable and must not be removed" + ) + }), + ("rebuildHookFailureDeletesNewContainer", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { @@ -1572,6 +1955,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ ) s.mock.throwingHandler = { args in guard args.first == "exec" else { return nil } + if args.contains(LifecycleRunner.userEnvProbeScript) { return nil } throw expectedError } var foundNewContainer = false @@ -1635,6 +2019,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ mock.handlers = [ { args in if args.first == "exec" { + if args.contains(LifecycleRunner.userEnvProbeScript) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("boom".utf8)) } return nil @@ -1861,6 +2248,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", + "postAttachCommand": "echo postAttachCustom", "customizations": { "vscode": { "settings": { "editor.fontSize": 14 }, @@ -1896,8 +2284,8 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ guest.writes.contains { $0.contains("settings.json") }, "settings still applied without --vscode" ) - let postAttachIdx = s.mock.calls.firstIndex { $0.arguments.last?.contains("postAttach") == true } - try MiniTest.expect(postAttachIdx == nil, "postAttach skipped without --vscode") + let postAttachIdx = s.mock.calls.firstIndex { $0.arguments.last?.contains("postAttachCustom") == true } + try MiniTest.expect(postAttachIdx != nil, "CLI-attach rebuild runs postAttach without --vscode") }), ("rebuildVscodeExtensionsThenOpenThenPostAttach", { @@ -1948,7 +2336,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ try MiniTest.expect(sequence.firstIndex(of: "extensions")! < sequence.firstIndex(of: "open")!) }), - ("rebuildVscodeOpenSoftFailSucceedsNoPostAttach", { + ("rebuildVscodeOpenSoftFailStillRunsPostAttach", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", @@ -1972,7 +2360,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ _ = try RebuildCommand.run(options: RebuildOptions(openVSCode: true), runtime: s.runtime) try MiniTest.expectEqual(launcher.calls.count, 0, "no launch without code CLI") let postAttachIdx = s.mock.calls.firstIndex { $0.arguments.last?.contains("postAttachCustom") == true } - try MiniTest.expect(postAttachIdx == nil, "postAttach skipped when open did not succeed") + try MiniTest.expect(postAttachIdx != nil, "open soft-fail must not skip CLI-attach postAttach") }), ("rebuildPostAttachFailureKeepsContainer", { diff --git a/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift b/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift index eb43aaf..ad3d0d4 100644 --- a/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift +++ b/Tests/adevcontainerTests/RecoveryOrchestratorTests.swift @@ -1203,6 +1203,9 @@ nonisolated(unsafe) let recoveryOrchestratorTests: [(String, () throws -> Void)] mock.handlers = [ { args in if args.first == "exec" { + if args.contains(LifecycleRunner.userEnvProbeScript) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } execCount += 1 return ProcessResult(exitCode: 42, stdout: Data(), stderr: Data()) } diff --git a/Tests/adevcontainerTests/StartCommandRecoveryTests.swift b/Tests/adevcontainerTests/StartCommandRecoveryTests.swift index 6b45a58..8a0ce86 100644 --- a/Tests/adevcontainerTests/StartCommandRecoveryTests.swift +++ b/Tests/adevcontainerTests/StartCommandRecoveryTests.swift @@ -211,6 +211,80 @@ nonisolated(unsafe) let startCommandRecoveryTests: [(String, () throws -> Void)] try MiniTest.expectEqual(verbs, ["list", "start"], "start recovery only lists and attempts start before delegating") }), + ("startRecoveryViaRebuildDoesNotDoubleRunPostStart", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postStartCommand": "echo start-recovery-postStart" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let mock = MockProcessRunner() + let entry = MockProcessRunner.containerListJSON( + id: startRecoveryName, + state: "stopped", + labels: labels + ) + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + return ProcessResult( + exitCode: 0, + stdout: try! JSONSerialization.data(withJSONObject: [entry]), + stderr: Data() + ) + } + if args.first == "start" { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("start failed".utf8)) + } + if args.first == "inspect" { + return ProcessResult( + exitCode: 0, + stdout: try! JSONSerialization.data(withJSONObject: entry), + stderr: Data() + ) + } + if args.first == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + var rebuildPostStart = 0 + StartCommand.rebuildOverride = { _ in + rebuildPostStart += 1 + return startRecoveryResult(startRecoveryName) + } + defer { StartCommand.rebuildOverride = nil } + + try StartCommand.run( + options: StartOptions(name: startRecoveryName), + runtime: runtime, + isTTY: true, + openEditorPrompt: startRecoveryPrompt(answers: ["y"]) + ) + try MiniTest.expectEqual(rebuildPostStart, 1, "rebuild create-path owns postStart") + let startBodies = mock.calls.compactMap { call -> String? in + guard call.arguments.first == "exec" else { return nil } + if let lc = call.arguments.firstIndex(of: "-lc"), lc + 1 < call.arguments.count { + return call.arguments[lc + 1] + } + return nil + } + try MiniTest.expect( + !startBodies.contains("echo start-recovery-postStart"), + "StartCommand must not exec postStart again after rebuild returns" + ) + }), + ("startRecoveryAddsNoConfigWritePath", { let (runtime, mock) = startRecoveryRuntime() var delegated: RebuildOptions? diff --git a/Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift b/Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift index 05f5e1f..b062b85 100644 --- a/Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift +++ b/Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift @@ -659,6 +659,157 @@ nonisolated(unsafe) let vscodeCustomizationsCommandTests: [(String, () throws -> try MiniTest.expectEqual(guest.unpackCalls.count, 0) }), + ("startStoppedBindRunsPostStart", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postStartCommand": "echo bind-postStart", + "customizations": { + "vscode": { + "extensions": ["pub.name"], + "settings": { "editor.tabSize": 2 } + } + } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + let mock = VSCodeCustCmdSupport.startRuntimeMock(resolved: resolved, state: "stopped") + let guest = MockVSCodeGuest() + guest.files[VSCodeCustomizationsApply.markerPath(home: guest.home)] = "stale" + let dl = MockVSCodeDownloader() + let restoreApply = VSCodeCustCmdSupport.installApply(guest: guest, downloader: dl) + defer { restoreApply() } + let launcher = MockVSCodeLauncher() + let restoreOpen = VSCodeCustCmdSupport.installOpen(launcher: launcher) + defer { restoreOpen() } + + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName, openVSCode: false), + runtime: runtime + ) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(VSCodeCustCmdSupport.shellBodies(from: mock).contains("echo bind-postStart")) + try MiniTest.expectEqual(dl.calls.count, 0) + try MiniTest.expectEqual(guest.unpackCalls.count, 0) + try MiniTest.expect(guest.files[VSCodeCustomizationsApply.settingsPath(home: guest.home)] == nil) + try MiniTest.expectEqual( + guest.files[VSCodeCustomizationsApply.markerPath(home: guest.home)]? + .trimmingCharacters(in: .whitespacesAndNewlines), + "stale" + ) + }), + + ("startAlreadyRunningDoesNotRunPostStart", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postStartCommand": "echo already-running-postStart", + "customizations": { + "vscode": { + "extensions": ["pub.name"], + "settings": { "editor.tabSize": 2 } + } + } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + let mock = VSCodeCustCmdSupport.startRuntimeMock(resolved: resolved, state: "running") + let guest = MockVSCodeGuest() + guest.files[VSCodeCustomizationsApply.markerPath(home: guest.home)] = "stale" + let dl = MockVSCodeDownloader() + let restoreApply = VSCodeCustCmdSupport.installApply(guest: guest, downloader: dl) + defer { restoreApply() } + + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName, openVSCode: false), + runtime: runtime + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect( + !VSCodeCustCmdSupport.shellBodies(from: mock).contains("echo already-running-postStart") + ) + try MiniTest.expectEqual(dl.calls.count, 0) + try MiniTest.expect(guest.files[VSCodeCustomizationsApply.settingsPath(home: guest.home)] == nil) + }), + + ("startRestartPostStartFailureDoesNotDelete", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postStartCommand": "exit 5", + "customizations": { + "vscode": { + "extensions": ["pub.name"], + "settings": { "editor.tabSize": 2 } + } + } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var labels = resolved.labels + labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue + labels[ContainerIdentity.labelWorkspaceFolder] = resolved.config.workspaceFolder + labels[ContainerIdentity.labelLocalFolder] = resolved.workspacePath + labels[ContainerIdentity.labelConfigFile] = resolved.configPath + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if args.contains(LifecycleRunner.userEnvProbeScript) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 5, stdout: Data(), stderr: Data("fail\n".utf8)) + } + if args.first == "delete" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let guest = MockVSCodeGuest() + guest.files[VSCodeCustomizationsApply.markerPath(home: guest.home)] = "stale" + let dl = MockVSCodeDownloader() + let restoreApply = VSCodeCustCmdSupport.installApply(guest: guest, downloader: dl) + defer { restoreApply() } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + try StartCommand.run( + options: StartOptions(name: resolved.containerName), + runtime: runtime + ) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.lifecycleFailed) + try MiniTest.expectEqual(err.property, "postStartCommand") + } + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + try MiniTest.expectEqual(dl.calls.count, 0) + try MiniTest.expect(guest.files[VSCodeCustomizationsApply.settingsPath(home: guest.home)] == nil) + }), + ("startWithVSCodeOpensWithoutApplyingCustomizations", { let ws = try TestRepo.makeTempWorkspace( configJSON: VSCodeCustCmdSupport.configJSON( @@ -730,12 +881,12 @@ nonisolated(unsafe) let vscodeCustomizationsCommandTests: [(String, () throws -> try MiniTest.expect(!VSCodeCustCmdSupport.shellBodies(from: mock).contains { $0.contains("postAttach") }) }), - ("startPostAttachSkippedWithoutVSCode", { + ("startPostAttachRunsWithoutVSCode", { let ws = try TestRepo.makeTempWorkspace( configJSON: VSCodeCustCmdSupport.configJSON( settings: true, extensions: true, - postAttach: "exit 99" + postAttach: "echo start-attach" ) ) defer { try? FileManager.default.removeItem(at: ws) } @@ -751,18 +902,12 @@ nonisolated(unsafe) let vscodeCustomizationsCommandTests: [(String, () throws -> defer { restoreOpen() } let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let stderr = try withEnabledStatusStderr { - try StartCommand.run( - options: StartOptions(name: resolved.containerName, openVSCode: false), - runtime: runtime - ) - } - try MiniTest.expectEqual(launcher.calls.count, 0) - try MiniTest.expect(!VSCodeCustCmdSupport.shellBodies(from: mock).contains { $0.contains("exit 99") }) - try MiniTest.expect( - stderr.contains("postAttach skipped") && stderr.contains("(no attach hook)"), - "start without --vscode must emit postAttach skip status when postAttach is present" + try StartCommand.run( + options: StartOptions(name: resolved.containerName, openVSCode: false), + runtime: runtime ) + try MiniTest.expectEqual(launcher.calls.count, 0) + try MiniTest.expect(VSCodeCustCmdSupport.shellBodies(from: mock).contains("echo start-attach")) try MiniTest.expectEqual(dl.calls.count, 0) try MiniTest.expectEqual(guest.unpackCalls.count, 0) try MiniTest.expect(guest.files[VSCodeCustomizationsApply.settingsPath(home: guest.home)] == nil) @@ -917,6 +1062,35 @@ nonisolated(unsafe) let vscodeCustomizationsCommandTests: [(String, () throws -> rebuild.contains("apply by default") || rebuild.contains("apply on the new"), "rebuild help states apply is default" ) + + for (label, text) in [ + ("usage", usage), + ("up help", up), + ("clone help", clone), + ("start help", start), + ("rebuild help", rebuild) + ] { + try MiniTest.expect( + !text.contains("does not run postStart"), + "\(label) must not say start skips postStart" + ) + try MiniTest.expect( + !text.contains("gates postAttach"), + "\(label) must not say --vscode gates postAttach" + ) + try MiniTest.expect( + !text.contains("postAttach only after successful open"), + "\(label) must not say postAttach runs only after successful open" + ) + try MiniTest.expect( + !text.contains("postAttachCommand runs only after successful open"), + "\(label) must not say postAttachCommand is open-gated" + ) + try MiniTest.expect( + !text.contains("skipped without flag or on open"), + "\(label) must not say postAttach is skipped without --vscode" + ) + } }), ("postAttachConfigLoaderRetainsVscodeFields", { diff --git a/Tests/adevcontainerTests/VSCodeOpenTests.swift b/Tests/adevcontainerTests/VSCodeOpenTests.swift index 0911809..1ab966a 100644 --- a/Tests/adevcontainerTests/VSCodeOpenTests.swift +++ b/Tests/adevcontainerTests/VSCodeOpenTests.swift @@ -54,6 +54,24 @@ private enum VSCodeOpenTestSupport { } } +/// Capture StatusPrinter phases without redirecting process stdio (avoids nested FD capture). +private func captureStartStatus(_ body: () throws -> Void) throws -> String { + let previousEnabled = StatusPrinter.enabled + let previousWrite = StatusPrinter.writeStderr + let previousPhase = StatusPrinter.hasEmittedPhase + defer { + StatusPrinter.enabled = previousEnabled + StatusPrinter.writeStderr = previousWrite + StatusPrinter.hasEmittedPhase = previousPhase + } + StatusPrinter.enabled = true + StatusPrinter.hasEmittedPhase = false + var buffer = Data() + StatusPrinter.writeStderr = { buffer.append($0) } + try body() + return String(data: buffer, encoding: .utf8) ?? "" +} + func expectPostSuccessConnectionHints(_ stderr: String, nameOrId: String) throws { // Connection hints are info weight (indented), not full `==>` phase lines. // Emitted by SuccessPresentation after command success (entry point / start). @@ -853,18 +871,581 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = return nil } ] - let launcher = MockVSCodeLauncher() - let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: "/opt/code") - defer { restore() } + let launcher = MockVSCodeLauncher() + let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: "/opt/code") + defer { restore() } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let stderr = try withEnabledStatusStderr { + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc", openVSCode: false), + runtime: runtime + ) + } + try MiniTest.expectEqual(launcher.calls.count, 0) + try expectPostSuccessConnectionHints(stderr, nameOrId: "adev-app-aaaabbbbcccc") + }), + ("realBindStartRunsInitializeCommandFromStampedHostPath", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-start-bind" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + var events: [String] = [] + host.handler = { _ in + events.append("initialize") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + events.append("start") + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + try MiniTest.expectEqual(events, ["initialize", "start"]) + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(host.calls[0].arguments.contains("echo init-start-bind")) + try MiniTest.expectEqual( + (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (ws.path as NSString).standardizingPath + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + }), + ("alreadyRunningStartDoesNotRunInitializeCommand", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-already-running" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "running", + labels: labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + try MiniTest.expect(host.calls.isEmpty) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + }), + ("initializeCommandFailureLeavesStoppedContainerStopped", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "exit 4" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + host.exitCode = 4 + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.lifecycleFailed) + try MiniTest.expectEqual(err.property, "initializeCommand") + } + try MiniTest.expectEqual(host.calls.count, 1) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + }), + ("realStartEmitsPostStartAndReadyStatus", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-start-status", + "onCreateCommand": "echo onCreate", + "updateContentCommand": "echo updateContent", + "postCreateCommand": "echo postCreate", + "postStartCommand": "echo config-postStart", + "postAttachCommand": "echo start-attach" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + host.handler = { _ in + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app", + DevContainerMetadataLabel.labelKey: #"{"postStartCommand":"echo feature-postStart"}"# + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let stderr = try captureStartStatus { + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + } + let mono = TerminalStyle.stripANSI(stderr) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expectEqual(host.calls.count, 1, "bind start must run initialize") + try MiniTest.expect(execBodies.contains("echo config-postStart"), "config postStart must run") + try MiniTest.expect(execBodies.contains("echo feature-postStart"), "feature postStart must remelt") + try MiniTest.expect(execBodies.contains("echo start-attach"), "CLI-attach postAttach must run") + try MiniTest.expect(!execBodies.contains("echo onCreate")) + try MiniTest.expect(!execBodies.contains("echo updateContent")) + try MiniTest.expect(!execBodies.contains("echo postCreate")) + try MiniTest.expect( + mono.contains("==> Running initializeCommand"), + "bind start must print initialize status when that hook runs" + ) + try MiniTest.expect( + mono.contains("==> Running postStartCommand"), + "real start must print postStart status when that hook is present" + ) + try MiniTest.expect( + mono.contains("==> Running postStartCommand (feature)"), + "real start must print feature-labeled postStart status" + ) + try MiniTest.expect( + mono.contains("==> Running postAttachCommand"), + "real start must print postAttach status when that hook runs" + ) + try MiniTest.expect(mono.contains("==> Ready"), "real start must print Ready after success") + }), + ("realStartRemeltsFeatureOnlyPostStartFromImageMetadata", { + // Live shape: config has no postStart; Features contributed postStart only via + // image `devcontainer.metadata` (container list labels do not carry the key). + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20" } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + let metaJSON = #"{"postStartCommand":"echo feature-only-postStart"}"# + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [DevContainerMetadataLabel.labelKey: metaJSON] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let stderr = try captureStartStatus { + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + } + let mono = TerminalStyle.stripANSI(stderr) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(execBodies.contains("echo feature-only-postStart"), "feature postStart from image metadata must remelt") + try MiniTest.expect( + mono.contains("==> Running postStartCommand (feature)"), + "real start must print feature-labeled postStart when that is the only hook" + ) + try MiniTest.expect(mono.contains("==> Ready")) + }), + ("realStartUnreadableConfigStillRunsMetadataPostStart", { + // Stamped config is missing; resume must still remelt image metadata hooks. + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20" } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let missing = ws.appendingPathComponent(".devcontainer/absent.json").path + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: missing, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + let metaJSON = #"{"postStartCommand":"echo meta-only-postStart","postAttachCommand":"echo meta-only-postAttach"}"# + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["image", "inspect"]) { + let obj: [String: Any] = [ + "labels": [DevContainerMetadataLabel.labelKey: metaJSON] + ] + let data = try! JSONSerialization.data(withJSONObject: obj) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "delete" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let stderr = try captureStartStatus { + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + } + let mono = TerminalStyle.stripANSI(stderr) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect( + execBodies.contains("echo meta-only-postStart"), + "unreadable config must still remelt metadata postStart" + ) + try MiniTest.expect( + execBodies.contains("echo meta-only-postAttach"), + "CLI-attach postAttach must remelt when config load is nil" + ) + try MiniTest.expect(!execBodies.contains("echo onCreate")) + try MiniTest.expect(!execBodies.contains("echo updateContent")) + try MiniTest.expect(!execBodies.contains("echo postCreate")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + try MiniTest.expect( + mono.contains("==> Running postStartCommand (feature)"), + "feature-only postStart must print feature-labeled status" + ) + try MiniTest.expect(mono.contains("==> Ready")) + }), + ("realStartRemeltsFeaturePostStartFromLocalFeatureWhenMetadataAbsent", { + // Live existing containers: Features never baked postStart into image/container + // metadata. Bind start must remelt from the resolved config's local feature + // package (equivalent remelt source) without rebuilding Features. + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "features": { + "./.devcontainer/features/hook-feature": {} + } + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let featDir = ws.appendingPathComponent(".devcontainer/features/hook-feature", isDirectory: true) + try FileManager.default.createDirectory(at: featDir, withIntermediateDirectories: true) + try """ + { + "id": "hook-feature", + "version": "1.0.0", + "name": "Hook", + "postStartCommand": "echo feature-local-postStart" + } + """.write( + to: featDir.appendingPathComponent("devcontainer-feature.json"), + atomically: true, + encoding: .utf8 + ) + try "#!/bin/sh\n".write( + to: featDir.appendingPathComponent("install.sh"), + atomically: true, + encoding: .utf8 + ) + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["image", "inspect"]) { + return ProcessResult(exitCode: 0, stdout: Data("{}".utf8), stderr: Data()) + } + if args.first == "exec" { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let stderr = try captureStartStatus { + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc"), + runtime: runtime + ) + } + let mono = TerminalStyle.stripANSI(stderr) + try MiniTest.expect(execBodies.contains("echo feature-local-postStart"), "local feature postStart must remelt when metadata is absent") + try MiniTest.expect( + mono.contains("==> Running postStartCommand (feature)"), + "real start must print feature-labeled postStart from equivalent remelt source" + ) + }), + ("alreadyRunningStartEmitsAlreadyRunningAndReady", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-already-status", + "postStartCommand": "echo postStart-already-status", + "postAttachCommand": "echo attach-already-status" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: ws.path, + ContainerIdentity.labelConfigFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path, + ContainerIdentity.labelWorkspaceFolder: "/workspaces/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "adev-app-aaaabbbbcccc", + state: "running", + labels: labels + ) + var execBodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let lc = args.firstIndex(of: "-lc"), lc + 1 < args.count { + execBodies.append(args[lc + 1]) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) - let stderr = try withEnabledStatusStderr { - try StartCommand.run( - options: StartOptions(name: "adev-app-aaaabbbbcccc", openVSCode: false), - runtime: runtime - ) + var stdout = "" + let stderr = try captureStartStatus { + try withCapturedStdout({ + try StartCommand.run( + options: StartOptions(name: "adev-app-aaaabbbbcccc", jsonOutput: true), + runtime: runtime + ) + }, capture: &stdout) } - try MiniTest.expectEqual(launcher.calls.count, 0) - try expectPostSuccessConnectionHints(stderr, nameOrId: "adev-app-aaaabbbbcccc") + let mono = TerminalStyle.stripANSI(stderr) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect( + !execBodies.contains("echo postStart-already-status"), + "already-running start must not run postStart" + ) + try MiniTest.expect( + !execBodies.contains("echo attach-already-status"), + "already-running start without --vscode must not run postAttach" + ) + try MiniTest.expect( + mono.contains("==> Container already running"), + "already-running start must emit a clear already-running status" + ) + try MiniTest.expect(mono.contains("==> Ready"), "already-running start must print Ready after success") + try MiniTest.expect(!mono.contains("==> Running initializeCommand")) + try MiniTest.expect(!mono.contains("==> Running postStartCommand")) + try MiniTest.expect( + !stdout.contains("==> "), + "start --json must keep StatusPrinter phases off stdout" + ) }), ("cloneWithVSCodeOpensAfterSuccess", { let restoreFeatures = CloneGitFeatureTestSupport.installOverrides() @@ -1052,6 +1633,11 @@ private enum PostAttachGateSupport { return shellBodies(from: call.arguments).first } } + + static func execUser(from execArgs: [String]) -> String? { + guard let index = execArgs.firstIndex(of: "-u"), index + 1 < execArgs.count else { return nil } + return execArgs[index + 1] + } } nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] = [ @@ -1201,6 +1787,7 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) try LifecycleRunner.applyPostAttachGate( openOutcome: .notRequested, + kind: .alreadyRunning, containerId: "c", config: withAttach, runtime: runtime @@ -1209,6 +1796,7 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] try LifecycleRunner.applyPostAttachGate( openOutcome: .skippedMissingCode, + kind: .alreadyRunning, containerId: "c", config: withAttach, runtime: runtime @@ -1217,6 +1805,27 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] try LifecycleRunner.applyPostAttachGate( openOutcome: .opened(uri: "u"), + kind: .alreadyRunning, + containerId: "c", + config: withAttach, + runtime: runtime + ) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" }) + + mock.calls.removeAll() + try LifecycleRunner.applyPostAttachGate( + openOutcome: .notRequested, + kind: .cliAttach, + containerId: "c", + config: withAttach, + runtime: runtime + ) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" }) + + mock.calls.removeAll() + try LifecycleRunner.applyPostAttachGate( + openOutcome: .skippedMissingCode, + kind: .cliAttach, containerId: "c", config: withAttach, runtime: runtime @@ -1260,21 +1869,22 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] try MiniTest.expectEqual(launcher.calls.count, 1) try MiniTest.expect(bodies.contains("echo postCreate")) try MiniTest.expect(bodies.contains("echo postAttach-ran")) - // postCreate before postAttach + // Default waitFor is updateContent: open + postAttach fire at that point, + // then postCreate continues. Open still precedes postAttach (finish order). let pc = bodies.firstIndex(of: "echo postCreate")! let pa = bodies.firstIndex(of: "echo postAttach-ran")! - try MiniTest.expect(pc < pa) + try MiniTest.expect(pa < pc) let obj = try JSONSerialization.jsonObject(with: try result.jsonData()) as! [String: Any] try MiniTest.expectEqual(obj["outcome"] as? String, "success") try MiniTest.expect(obj["vscode"] == nil) }), - // 7.2 — without --vscode: skip, no exec of postAttach - ("upPostAttachSkippedWithoutVSCode", { + // CLI attach without --vscode: postAttach still runs after waitFor + ("upPostAttachRunsWithoutVSCode", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99", + "postAttachCommand": "echo postAttach-ran", "postCreateCommand": "echo postCreate" } """) @@ -1298,16 +1908,19 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] ) try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(launcher.calls.count, 0) - try MiniTest.expectEqual(bodies, ["echo postCreate"]) - try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) + try MiniTest.expect(bodies.contains("echo postAttach-ran")) + try MiniTest.expect(bodies.contains("echo postCreate")) + let pa = bodies.firstIndex(of: "echo postAttach-ran")! + let pc = bodies.firstIndex(of: "echo postCreate")! + try MiniTest.expect(pa < pc, "default waitFor: postAttach at updateContent, before postCreate") }), - // 7.3 — open soft-fail: no postAttach, lifecycle success - ("upPostAttachSkippedWhenOpenSoftFails", { + // open soft-fail must not suppress CLI-attach postAttach + ("upPostAttachRunsWhenOpenSoftFails", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99" + "postAttachCommand": "echo postAttach-ran" } """) defer { try? FileManager.default.removeItem(at: ws) } @@ -1330,7 +1943,7 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] ) try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(launcher.calls.count, 0) - try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) + try MiniTest.expect(bodies.contains("echo postAttach-ran")) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), @@ -1488,11 +2101,66 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] try MiniTest.expect(bodies.contains("echo start-attach")) }), - ("startPostAttachSkippedWithoutVSCode", { + ("startPostAttachRunsWithoutVSCode", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99" + "postAttachCommand": "echo start-attach" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var labels = resolved.labels + labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue + labels[ContainerIdentity.labelWorkspaceMode] = ContainerIdentity.workspaceModeBind + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "stopped", + labels: labels, + image: "alpine:3.20" + ) + var bodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let b = PostAttachGateSupport.shellBodies(from: args).first { + bodies.append(b) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let launcher = MockVSCodeLauncher() + let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: "/opt/code") + defer { restore() } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName, openVSCode: false), + runtime: runtime + ) + try MiniTest.expectEqual(launcher.calls.count, 0) + try MiniTest.expect(bodies.contains("echo start-attach")) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + }), + + ("startPostAttachRunsWhenOpenSoftFails", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postAttachCommand": "echo start-attach" } """) defer { try? FileManager.default.removeItem(at: ws) } @@ -1531,6 +2199,65 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] } ] let launcher = MockVSCodeLauncher() + let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: nil) + defer { restore() } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName, openVSCode: true), + runtime: runtime + ) + try MiniTest.expectEqual(launcher.calls.count, 0) + try MiniTest.expect(bodies.contains("echo start-attach")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + }), + + ("alreadyRunningStartSkipsPostAttachWithoutSuccessfulOpen", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "initializeCommand": "echo init-already", + "postStartCommand": "echo postStart-already", + "postAttachCommand": "exit 99" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var labels = resolved.labels + labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue + labels[ContainerIdentity.labelWorkspaceMode] = ContainerIdentity.workspaceModeBind + labels[ContainerIdentity.labelLocalFolder] = ws.path + labels[ContainerIdentity.labelConfigFile] = ws.appendingPathComponent(".devcontainer/devcontainer.json").path + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "running", + labels: labels, + image: "alpine:3.20" + ) + var bodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let b = PostAttachGateSupport.shellBodies(from: args).first { + bodies.append(b) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let launcher = MockVSCodeLauncher() let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: "/opt/code") defer { restore() } let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) @@ -1541,28 +2268,39 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] ) } try MiniTest.expectEqual(launcher.calls.count, 0) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) + try MiniTest.expect(!bodies.contains("echo postStart-already")) try MiniTest.expect( stderr.contains("postAttach skipped") && stderr.contains("(no attach hook)"), - "start without --vscode must emit postAttach skip status when postAttach is present" + "already-running start without --vscode must emit one postAttach skip status" ) }), - ("startPostAttachSkippedWhenOpenSoftFails", { + ("alreadyRunningStartRunsPostAttachAfterSuccessfulVSCodeOpen", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99" + "initializeCommand": "echo init-already", + "postStartCommand": "echo postStart-already", + "postAttachCommand": "echo start-attach" } """) defer { try? FileManager.default.removeItem(at: ws) } + let host = RecordingHostProcessRunner() + let restoreHost = RecordingHostProcessRunner.install(host) + defer { restoreHost() } let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) var labels = resolved.labels labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue labels[ContainerIdentity.labelWorkspaceMode] = ContainerIdentity.workspaceModeBind + labels[ContainerIdentity.labelLocalFolder] = ws.path + labels[ContainerIdentity.labelConfigFile] = ws.appendingPathComponent(".devcontainer/devcontainer.json").path + labels[ContainerIdentity.labelWorkspaceFolder] = resolved.config.workspaceFolder let entry = MockProcessRunner.containerListJSON( id: resolved.containerName, - state: "stopped", + state: "running", labels: labels, image: "alpine:3.20" ) @@ -1574,9 +2312,6 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] let data = try! JSONSerialization.data(withJSONObject: [entry]) return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) } - if args.first == "start" { - return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) - } if args.first == "inspect" { let data = try! JSONSerialization.data(withJSONObject: entry) return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) @@ -1591,15 +2326,116 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] } ] let launcher = MockVSCodeLauncher() - let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: nil) + let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: "/opt/code") defer { restore() } let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) try StartCommand.run( options: StartOptions(name: resolved.containerName, openVSCode: true), runtime: runtime ) + try MiniTest.expectEqual(launcher.calls.count, 1) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect(bodies.contains("echo start-attach")) + try MiniTest.expect(!bodies.contains("echo postStart-already")) + }), + + ("alreadyRunningStartOpenSoftFailDoesNotRunPostAttach", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "postAttachCommand": "exit 99" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var labels = resolved.labels + labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue + labels[ContainerIdentity.labelWorkspaceMode] = ContainerIdentity.workspaceModeBind + labels[ContainerIdentity.labelWorkspaceFolder] = resolved.config.workspaceFolder + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "running", + labels: labels, + image: "alpine:3.20" + ) + var bodies: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let data = try! JSONSerialization.data(withJSONObject: [entry]) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "inspect" { + let data = try! JSONSerialization.data(withJSONObject: entry) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + if let b = PostAttachGateSupport.shellBodies(from: args).first { + bodies.append(b) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let launcher = MockVSCodeLauncher() + let restore = VSCodeOpenTestSupport.install(launcher: launcher, resolverPath: nil) + defer { restore() } + let previousWarn = StatusPrinter.onWarning + var warnings: [String] = [] + StatusPrinter.onWarning = { warnings.append($0) } + defer { StatusPrinter.onWarning = previousWarn } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let stderr = try withEnabledStatusStderr { + try StartCommand.run( + options: StartOptions(name: resolved.containerName, openVSCode: true), + runtime: runtime + ) + } try MiniTest.expectEqual(launcher.calls.count, 0) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) + try MiniTest.expect( + warnings.contains { $0.lowercased().contains("code") || $0.lowercased().contains("vscode open") }, + "already-running open soft-fail must warn" + ) + try MiniTest.expect( + stderr.contains("postAttach skipped"), + "already-running open soft-fail must not execute postAttach" + ) + }), + + ("upPostAttachUsesRemoteUserNotContainerUser", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { + "image": "alpine:3.20", + "remoteUser": "alice", + "containerUser": "bob", + "userEnvProbe": "none", + "postAttachCommand": "echo postAttach-user" + } + """) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + var attachUsers: [String?] = [] + let mock = PostAttachGateSupport.freshUpMock(resolved: resolved) { args in + if let b = PostAttachGateSupport.shellBodies(from: args).first, b.contains("postAttach-user") { + attachUsers.append(PostAttachGateSupport.execUser(from: args)) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: ws.path, skipPull: true, openVSCode: false), + runtime: runtime, + localEnv: [:] + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect(!attachUsers.isEmpty, "CLI-attach postAttach must exec") + try MiniTest.expect(attachUsers.allSatisfy { $0 == "alice" }, "postAttach must use remoteUser alice") + try MiniTest.expect(!attachUsers.contains { $0 == "bob" }, "postAttach must not use containerUser bob") }), ("startPostAttachFailureFailsKeepContainer", { @@ -1820,14 +2656,14 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] try MiniTest.expect(bodies.contains("echo clone-attach")) }), - ("clonePostAttachSkippedWithoutVSCode", { + ("clonePostAttachRunsWithoutVSCode", { let restoreFeatures = CloneGitFeatureTestSupport.installOverrides() defer { restoreFeatures() } let git = MockGitClient() git.configJSONToWrite = """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99" + "postAttachCommand": "echo clone-attach" } """ var bodies: [String] = [] @@ -1861,17 +2697,18 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] ) try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(launcher.calls.count, 0) - try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) + try MiniTest.expect(bodies.contains("echo clone-attach")) + try MiniTest.expect(!FileManager.default.fileExists(atPath: git.fetchConfigCalls[0].directory)) }), - ("clonePostAttachSkippedWhenOpenSoftFails", { + ("clonePostAttachRunsWhenOpenSoftFails", { let restoreFeatures = CloneGitFeatureTestSupport.installOverrides() defer { restoreFeatures() } let git = MockGitClient() git.configJSONToWrite = """ { "image": "alpine:3.20", - "postAttachCommand": "exit 99" + "postAttachCommand": "echo clone-attach" } """ var bodies: [String] = [] @@ -1904,7 +2741,8 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] localEnv: [:] ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) + try MiniTest.expect(bodies.contains("echo clone-attach")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), ("clonePostAttachFailureFailsKeepContainer", { diff --git a/specs/changes/align-official-lifecycle/proposal.md b/specs/changes/align-official-lifecycle/proposal.md new file mode 100644 index 0000000..30b89cd --- /dev/null +++ b/specs/changes/align-official-lifecycle/proposal.md @@ -0,0 +1,86 @@ +# Proposal: Align official Dev Containers lifecycle + +## Intent + +This CLI already runs a subset of Dev Containers lifecycle hooks, but it rejects official keys (`initializeCommand`, `waitFor`, `userEnvProbe`, `shutdownAction`), runs object-map entries sequentially, gates `postAttachCommand` on `--vscode` open, and locks bare `adevcontainer start` to runtime-only (no `postStartCommand`, including volume-mode). Official Environment Creation / Resume expects host `initializeCommand`, parallel named entries, `waitFor`-gated tool connect, probed user env on injected processes, `postStart` on every successful start of a stopped container, and `postAttach` when the supporting tool attaches. This change aligns the product lifecycle with that official contract for this CLI. + +## Scope + +- Change id: **`align-official-lifecycle`** +- New bounded context (full official lifecycle alignment), not an in-place edit of active `vscode-customizations-up-clone-rebuild` +- Live contract to update: realized `specs/lifecycle-hooks.md`, `specs/managed-lifecycle.md`, `specs/vscode.md`, `specs/core.md`, `specs/clone.md`, plus active `specs/changes/vscode-customizations-up-clone-rebuild/spec.md` +- **ADD** host `initializeCommand`, `waitFor` readiness, `userEnvProbe` merge, `shutdownAction` admission, and feature `postStart` remelt on resume +- **MODIFY** lifecycle hook surface (admitted keys, parallel object-map, `postStart` on every real start, postAttach policy pointer) +- **MODIFY** Start managed container / lifecycle-hooks-on-start lock in both realized managed-lifecycle and the active vscode-customizations delta — this change **supersedes** that hook lock +- **MODIFY** postAttachCommand policy (CLI-only) to the CLI attach model below +- **MODIFY** Up lifecycle matrix (initialize, waitFor, postAttach, start-stopped feature postStart) and core admitted lifecycle property list +- **MODIFY** clone create-path note that currently claims postAttach is `--vscode`-gated +- **REPLACE** the “Volume-mode start runs no hooks” scenario (it becomes false) +- Unchanged and still in force: vscode customizations apply MUST NOT run on `adevcontainer start`; string/argv `sh -lc` vs argv rules; no new product commands + +## Non-goals + +- Cloud prebuild or periodic `updateContentCommand` +- Docker Compose / multi-service +- True IDE attach listener or waiting for VS Code Server ready +- Last-tool-window-close auto-stop (`shutdownAction` is admitted; the CLI cannot observe last-window close) +- `updateRemoteUserUID` (macOS / Apple container; not Linux bind UID sync) +- Applying vscode settings/extensions on `start` (that vscode-customizations lock stays) +- Changing string/argv shell invocation (`sh -lc` for strings remains) +- Inventing new product commands +- Feature-contributed `initializeCommand` (host-only; not in official feature metadata merge) +- Treating `adevcontainer exec` as attach +- Materializing the entire guest workspace (or re-fetching git) so volume-mode `initializeCommand` can use repo-root paths such as `./scripts/…` +- Starting a volume-mode container solely so `start` can run `initializeCommand` + +## Approach + +Lite SDD: this proposal + outcome delta `spec.md` only (no `design.md`, no `tasks.md` in this propose step). + +Admit the remaining official lifecycle keys and run them on existing commands (`up`, `clone`, `rebuild`, `start`, `stop`, `exec`) as the CLI’s Environment Creation / Resume / attach model. Object-map stages become official parallel. `postStartCommand` runs after every successful start of a previously stopped container, including bare `start` in bind and volume modes, with config then remelted feature `postStart`. `postAttachCommand` ungates from `--vscode`: the CLI is the supporting tool, so attach runs at the end of successful `up` / `clone` / `rebuild` and after a real `start`; already-running `start` attaches only when `--vscode` open succeeds; open soft-fail MUST NOT suppress a CLI attach that would otherwise run. + +**Live-contract conflict (active `vscode-customizations-up-clone-rebuild`):** that delta locks bare `start` as runtime-only — MUST NOT run hooks including `postStartCommand` — and keeps the scenario “Volume-mode start runs no hooks”. This change **supersedes that start hook lock** and replaces that scenario. It does **not** supersede “`start` MUST NOT apply vscode customizations”. postAttach rows in that delta that still say “only after successful `--vscode` open” are also superseded by this attach model. + +## Decision index + +- **Align the full official lifecycle for this CLI:** not a postStart-on-start-only tweak. Rationale: remaining official keys are currently rejected or wrong; one change keeps Creation / Resume / attach consistent. +- **`initializeCommand`:** admit; run on the host at the start of `up` / `clone` / `rebuild` and of a real start (stopped→running) when a host workspace exists (bind via stamped `local_folder` / config). Volume-mode / clone-origin **rebuild** with no usable host workspace: still run, cwd a temporary host workspace root that contains the current guest `.devcontainer/` (and root `.devcontainer.json` if that is the config) so `bash .devcontainer/…` works; remove the temp after the hook (success or fail). Missing `.devcontainer` dir must not skip (host-global commands still run). Repo-root paths like `./scripts/…` are not required. Do not copy the whole guest workspace. Do not re-fetch git (live volume config may have been edited). Volume-mode **start** with no host workspace: skip with a warning (cannot obtain guest files before start without starting). Already-running `start`: do not run (no start occurred). +- **Create-path hooks stay create-path:** `onCreate` / `updateContent` / `postCreate` remain `up` / `clone` / `rebuild` fresh create only. No cloud periodic `updateContent`. Feature hooks still merge on create-path. +- **`postStartCommand` on every real start:** MUST run after successful start of a previously stopped container on `adevcontainer start` (bind and volume) and on `up` start-stopped. Config hook then remelted feature `postStart`. Already-running `start` / `up` reuse: MUST NOT re-run. Restart/start failure: fail the command, MUST NOT delete. `start` recovery that delegates to `rebuild` MUST NOT double-run `postStart` after rebuild. +- **`postAttachCommand` CLI attach model:** ungate from `--vscode`. Official Environment Resume runs postStart and postAttach. Run postAttach at the end of successful `up` / `clone` / `rebuild`, and after a real `start`. Already-running `start`: only when `--vscode` open succeeds. `exec` is not attach. When `--vscode` is set, postAttach still runs after successful open; if open soft-fails on a path that would otherwise run postAttach as CLI attach, still run postAttach. Manual IDE UI attach without the CLI remains out of scope. +- **`waitFor`:** admit official enum; default `updateContentCommand`. Block Ready / optional vscode open / postAttach until the named stage inclusive has finished. Later create-path hooks MAY run in the background; the process SHOULD still wait for them before exiting so delete-on-fail and exit code stay correct. Do not emit success JSON until waitFor succeeded. A later background hook failure after Ready was emitted still fails the command exit; create-path delete-on-fail still applies to onCreate / updateContent / postCreate / first postStart; restart-class hooks MUST NOT delete. +- **Object-map form:** official parallel. Each named entry in a stage MUST run concurrently; the stage succeeds only if every entry exits 0. Replaces sequential sorted-by-name as the required behavior. +- **`userEnvProbe`:** admit `none` / `interactiveShell` / `loginShell` / `loginInteractiveShell`; default `loginInteractiveShell`. When not `none`, probe the remote connection user’s shell environment inside the container and merge into subsequent injected processes (lifecycle execs and `adevcontainer exec`). `none` skips. +- **`shutdownAction`:** admit enum. `stopContainer` (default for this image/Dockerfile product) matches existing `adevcontainer stop`. `none` does not change explicit `stop` (last-window-close remains out of scope). `stopCompose` MUST fail closed. +- **Shell invocation unchanged:** keep existing string → `sh -lc` and argv-without-shell rules. +- **vscode-customizations conflict:** this change supersedes the start hook lock only. `start` still MUST NOT apply vscode settings or extensions. +- **Image-metadata hooks survive create and resume:** official image fragments merge with config. Bake the **union** of base-image `devcontainer.metadata` and feature contributions onto the derived `LABEL` (not features-only). Fresh `up` / `rebuild` / `clone` with **empty features** still apply base-image metadata create-path + resume hooks. On `start`, if config load is nil, still remelt container+image metadata and run feature-only postStart (`failKeepContainer`); postAttach same when the CLI-attach gate would run. `up` / `rebuild` finish remelt MUST NOT replace-away base-image postAttach that apply already unioned. Do not remelt onCreate / updateContent / postCreate on resume. `initializeCommand` stays host/config-only. + +## Clarifications + +- **Q:** Align only postStart-on-start, or the official lifecycle for this CLI? + **A:** The official lifecycle for this CLI, not just postStart-on-start. +- **Q:** When does `initializeCommand` run? + **A:** On the host at the start of `up` / `clone` / `rebuild` and of a real start when a host workspace exists. Volume-mode / clone-origin rebuild with no usable host workspace still runs it on a temporary workspace root that contains the guest config directory/files. Volume-mode start with no host workspace skips with a warning. Already-running `start` does not run it. +- **Q:** How does volume-mode rebuild run `initializeCommand` when there is no durable host workspace? + **A:** Option 2: place the current guest `.devcontainer/` directory (and root `.devcontainer.json` if that is the config) onto a host temporary workspace root; run the hook with that cwd so `bash .devcontainer/…` works; remove the temp after the hook (success or fail). Guest files are already readable while rebuild reads config from the volume. Missing `.devcontainer` must not skip. Repo-root paths like `./scripts/…` are not required. Do not copy the whole guest workspace. Do not re-fetch git. Volume-mode start stays skip+warn. +- **Q:** Do onCreate / updateContent / postCreate run on resume? + **A:** No. Create-path only (`up` / `clone` / `rebuild` fresh create). No cloud periodic updateContent. Feature hooks still merge on create-path. +- **Q:** Does bare `start` run `postStartCommand`, including volume-mode? + **A:** Yes, after every successful start of a previously stopped container (bind and volume `start`, and `up` start-stopped). Remelt feature postStart on resume. Already-running must not re-run. Restart failure must not delete. Rebuild-delegated start recovery must not double-run postStart. +- **Q:** Is postAttach still `--vscode`-only? + **A:** No. CLI attach model: end of successful `up` / `clone` / `rebuild`; after a real `start`; already-running `start` only when `--vscode` open succeeds; `exec` is not attach; open soft-fail must not suppress a CLI attach that would otherwise run. +- **Q:** How should `waitFor` interact with Ready, open, postAttach, and process exit? + **A:** Default `updateContentCommand`. Block Ready / open / postAttach until the named stage inclusive finishes. Later hooks may be backgrounded; Ready/open may happen at waitFor while the process continues remaining hooks. Do not emit success JSON until waitFor succeeded. Process exit still reflects remaining hook success. Create-path delete-on-fail still applies to onCreate / updateContent / postCreate / first postStart. Restart-class failures must not delete. +- **Q:** Object-map sequential or official parallel? + **A:** Official parallel. Stage succeeds only if every named entry exits 0. +- **Q:** Admit `userEnvProbe`? + **A:** Yes. Official enum; default `loginInteractiveShell`. Probe and merge into subsequent injected processes unless `none`. +- **Q:** Admit `shutdownAction`? + **A:** Yes. `stopContainer` is the image/Dockerfile default and matches explicit `stop`. `none` does not disable explicit `stop`. `stopCompose` fails closed. Last-window-close auto-stop stays out of scope. +- **Q:** Change string/argv shell invocation? + **A:** No. Keep existing `sh -lc` for strings. +- **Q:** Does this override the vscode-customizations start apply lock? + **A:** No. Only the start hook lock is superseded. `start` still MUST NOT apply vscode customizations. +- **Q:** Do base-image `devcontainer.metadata` hooks survive Features bake and resume remelt? + **A:** Yes. Bake the unioned contributions (base-image + features) onto derived `LABEL devcontainer.metadata`. Empty-features create still applies image-metadata create-path + resume hooks. `start` with unreadable config still remelts metadata postStart / CLI-attach postAttach. Finish remelt unions with apply (does not replace-away base-image postAttach). diff --git a/specs/changes/align-official-lifecycle/spec.md b/specs/changes/align-official-lifecycle/spec.md new file mode 100644 index 0000000..8856b7c --- /dev/null +++ b/specs/changes/align-official-lifecycle/spec.md @@ -0,0 +1,839 @@ +# Change Spec: align-official-lifecycle + +Delta against the live contract (realized `specs/*.md` plus active `specs/changes/vscode-customizations-up-clone-rebuild/spec.md`). RFC 2119 keywords apply. This change **supersedes** that active delta’s start hook lock (`start` MUST NOT run hooks / `postStartCommand`; “Volume-mode start runs no hooks”) and any postAttach rows that still require `--vscode` + successful open. It does **not** supersede “`start` MUST NOT apply vscode customizations”. + +## ADDED Requirements + +### Requirement: initializeCommand host execution + +The CLI MUST admit `initializeCommand` with the same string, argv-array, and object-map forms as other lifecycle commands. Invalid form MUST fail resolve with a structured error naming `initializeCommand`. Omitted or empty object-map MUST be a no-op. + +When `initializeCommand` is present, the CLI MUST run it on the **host** (not via container exec) at the start of: + +- each `adevcontainer up`, `adevcontainer clone`, and `adevcontainer rebuild` invocation when a host workspace exists, +- each `adevcontainer rebuild` of a volume-mode or clone-origin container when **no** usable host workspace exists, using a temporary host workspace root as specified below, and +- a **real start** (stopped → running) of a managed container when a host workspace exists. + +A host workspace exists when the command is operating on a bind-mode workspace, or when stamped `devcontainer.local_folder` / config identify a usable host path (including clone’s config-fetch or retained-checkout directory during `clone` / `rebuild` of a clone-origin container that still has that host path). When a usable host workspace exists, `rebuild` MUST use that path as the hook cwd and MUST NOT substitute a temporary workspace root. + +Volume-mode `adevcontainer start` with no usable host workspace MUST skip `initializeCommand` and MUST emit a warning that the host command cannot run. Guest files are not available before `start` without starting the container; `start` MUST NOT start solely to obtain them. Already-running `adevcontainer start` MUST NOT run `initializeCommand` (no start occurred). + +**Volume-mode / clone-origin rebuild with no usable host workspace.** When `initializeCommand` is present, the CLI MUST still run it on the host. The hook cwd MUST be a temporary host workspace root that contains the **current** guest config directory/files: + +- the guest `.devcontainer/` directory when that directory exists in the guest workspace, and +- the guest root `.devcontainer.json` when that file is the config. + +Those contents MUST come from the current guest workspace (the same live, possibly edited files rebuild already reads for config). The CLI MUST NOT re-fetch the git remote solely to obtain them. The temporary workspace root is **not** a full copy of the guest workspace; commands that depend on other repo-root paths (for example `./scripts/…`) are NOT required to work. A command of the form `bash .devcontainer/…` MUST be able to resolve that path from the hook cwd when the guest `.devcontainer/` directory exists. + +Absence of a guest `.devcontainer/` directory MUST NOT skip the hook: a host-global `initializeCommand` (no relative config-dir path) MUST still run, with cwd still a temporary workspace root. + +On this path the CLI MUST run `initializeCommand` after the current guest workspace is readable and **before** the old container is deleted and **before** the new container is created. After `initializeCommand` returns — success or failure — the CLI MUST remove that temporary workspace root. If removal fails, the CLI MUST emit a warning and MUST NOT fail the command solely due to that removal failure. + +Object-map entries MUST run concurrently on the host per **Lifecycle hook surface**. String vs argv invocation MUST keep the existing product rules (`sh -lc` for strings; argv without a shell). Failure of `initializeCommand` MUST fail the command with a structured error naming `initializeCommand`. On create-path, the CLI MUST NOT create the managed container if `initializeCommand` fails. On volume-mode / clone-origin `rebuild` with no usable host workspace, that failure MUST also leave the old container in place. On a real start, the CLI MUST NOT start the stopped container if `initializeCommand` fails. On `up` reuse of an already-running container, `initializeCommand` still MUST run when a host workspace exists; failure MUST fail `up` and MUST NOT stop or delete the running container. `initializeCommand` is not a create-path delete-on-fail hook. + +#### Scenario: up runs initializeCommand on the host before create + +- Given a bind-mode workspace whose config has `initializeCommand` that exits 0 and no existing container +- When the user runs `adevcontainer up` +- Then the CLI runs `initializeCommand` on the host before creating the container +- And `up` continues through create-path hooks and succeeds + +#### Scenario: clone runs initializeCommand on the host checkout + +- Given a cloneable repo whose config has `initializeCommand` that exits 0 +- When the user runs `adevcontainer clone ` +- Then the CLI runs `initializeCommand` on the host config-fetch or retained-checkout directory before creating the container +- And clone continues and succeeds + +#### Scenario: real bind start runs initializeCommand from stamped host path + +- Given a stopped bind-mode managed container with a usable stamped host workspace and a config `initializeCommand` that exits 0 +- When the user runs `adevcontainer start --name ` +- Then the CLI runs `initializeCommand` on that host workspace before starting the container +- And then starts the container + +#### Scenario: volume-mode start without host workspace skips initializeCommand + +- Given a stopped volume-mode managed container, no usable host workspace, and a config that had `initializeCommand` at create time +- When the user runs `adevcontainer start --name ` +- Then the CLI starts the container without running `initializeCommand` +- And stderr includes a warning that the host command cannot run + +#### Scenario: already-running start does not run initializeCommand + +- Given a managed container that is already running and a config with `initializeCommand` +- When the user runs `adevcontainer start --name ` +- Then the command succeeds as a no-op start +- And `initializeCommand` does not run + +#### Scenario: up reuse still runs initializeCommand on the host + +- Given a matching already-running bind-mode container and a config with `initializeCommand` that exits 0 +- When the user runs `adevcontainer up` +- Then the CLI runs `initializeCommand` on the host +- And onCreate / updateContent / postCreate / postStart do not run + +#### Scenario: initializeCommand failure blocks create + +- Given no existing container and a config whose `initializeCommand` exits non-zero +- When the user runs `adevcontainer up` +- Then `up` fails with a structured error naming `initializeCommand` +- And no managed container is created + +#### Scenario: initializeCommand failure leaves a stopped container stopped + +- Given a stopped managed container with a usable host workspace and a config whose `initializeCommand` exits non-zero +- When the user runs `adevcontainer start --name ` +- Then the command fails with a structured error naming `initializeCommand` +- And the container remains stopped + +#### Scenario: volume-mode rebuild without host workspace still runs initializeCommand + +- Given a volume-mode or clone-origin managed container, no usable host workspace, a guest `.devcontainer/` directory, and a config `initializeCommand` of the form `bash .devcontainer/…` that exits 0 +- When the user runs `adevcontainer rebuild --name ` +- Then the CLI runs `initializeCommand` on the host with cwd a temporary workspace root that contains that guest `.devcontainer/` directory +- And `bash .devcontainer/…` can resolve that path from that cwd +- And the new container is created only after `initializeCommand` succeeds +- And that temporary workspace root is removed after the hook + +#### Scenario: volume-mode rebuild initialize temp is removed after failure + +- Given a volume-mode or clone-origin managed container, no usable host workspace, and a config whose `initializeCommand` exits non-zero +- When the user runs `adevcontainer rebuild --name ` +- Then `rebuild` fails with a structured error naming `initializeCommand` +- And no new container is created +- And the old container remains +- And the temporary workspace root is removed after the hook + +#### Scenario: missing .devcontainer directory does not skip initializeCommand on volume rebuild + +- Given a volume-mode or clone-origin managed container, no usable host workspace, a root `.devcontainer.json` as the config, no guest `.devcontainer/` directory, and a host-global `initializeCommand` that exits 0 +- When the user runs `adevcontainer rebuild --name ` +- Then the CLI still runs `initializeCommand` on the host with cwd a temporary workspace root that contains that root `.devcontainer.json` +- And the hook is not skipped solely because `.devcontainer/` is absent + +#### Scenario: volume-mode rebuild initialize is not a full workspace checkout + +- Given a volume-mode or clone-origin managed container, no usable host workspace, a guest `.devcontainer/` directory, and an `initializeCommand` that only needs paths under `.devcontainer/` +- When the user runs `adevcontainer rebuild --name ` +- Then the hook runs successfully from a temporary workspace root that contains that `.devcontainer/` directory +- And success does not depend on other guest workspace paths such as `./scripts/…` being present on the host + +#### Scenario: volume-mode rebuild with a retained host checkout uses that path + +- Given a clone-origin managed container whose retained host checkout is still usable and a config `initializeCommand` that exits 0 +- When the user runs `adevcontainer rebuild --name ` +- Then the CLI runs `initializeCommand` with cwd that host checkout +- And it does not substitute a temporary workspace root created solely for the hook + +--- + +### Requirement: waitFor readiness + +The CLI MUST admit `waitFor` as an enum of `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, or `postStartCommand`. Omitted `waitFor` MUST default to official `updateContentCommand`. An unknown value MUST fail resolve with a structured error naming `waitFor`. + +`waitFor` MUST control when the supporting tool may connect. The command MUST block Ready, optional vscode open, and `postAttachCommand` until the named stage **inclusive** has finished successfully. Stages after `waitFor` MAY complete in the background. The process SHOULD still wait for those remaining hooks before exiting so create-path delete-on-fail and the process exit code remain correct. + +The CLI MUST NOT emit success JSON until the `waitFor` stage has succeeded. Ready and connection hints MAY be emitted once `waitFor` is satisfied, even while later create-path hooks are still running. Optional vscode open MAY happen after `waitFor` is satisfied and MUST NOT wait for later background hooks solely to open. + +Hook order is unchanged: create-path remains initialize (host) → onCreate → updateContent → postCreate → postStart. First-create `postStartCommand` still belongs to a successful start and MUST still be initiated after `postCreateCommand`, even when `waitFor` is `updateContentCommand` and Ready MAY occur before postCreate / postStart complete. Default `waitFor` therefore means `postCreateCommand` MAY run in the background after Ready. + +On resume (real start / `up` start-stopped), create-path stages from a prior successful create are already satisfied. If `waitFor` names a create-path stage (`initializeCommand` through `postCreateCommand`), Ready / open / postAttach MUST NOT wait for those stages again. Ready / open / postAttach MUST wait for this invocation’s `postStartCommand` only when `waitFor` is `postStartCommand`; otherwise on resume they MAY occur before this invocation’s `postStartCommand`. + +Failure of a background post-`waitFor` hook MUST fail the command (non-zero) once observed. If Ready was already emitted, the process MUST still exit non-zero and MUST NOT emit a later success JSON. Create-path delete-on-fail still MUST apply to `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and first-create `postStartCommand`. Restart-class hook failure (`postStartCommand` or `postAttachCommand` on a previously successful create) MUST NOT delete the container. + +#### Scenario: default waitFor allows Ready before postCreate + +- Given a fresh create whose config omits `waitFor` and has `updateContentCommand`, `postCreateCommand`, and `postStartCommand` each exiting 0 +- When the user runs `adevcontainer up` +- Then Ready MAY be emitted after `updateContentCommand` succeeds and before `postCreateCommand` finishes +- And `postCreateCommand` then `postStartCommand` still run +- And the process does not exit 0 until those remaining hooks succeed + +#### Scenario: waitFor postCreateCommand delays Ready until postCreate + +- Given a fresh create whose `waitFor` is `postCreateCommand` +- When the user runs `adevcontainer up` +- Then Ready, optional vscode open, and postAttach do not occur before `postCreateCommand` finishes +- And `postStartCommand` is still initiated after `postCreateCommand` + +#### Scenario: success JSON waits for waitFor not for later hooks + +- Given a fresh create with default `waitFor` and `--json` +- When `updateContentCommand` has succeeded and `postCreateCommand` is still running +- Then the CLI MUST NOT have emitted success JSON before `updateContentCommand` succeeded +- And success JSON MAY be emitted before `postCreateCommand` finishes + +#### Scenario: background create-path hook failure still deletes + +- Given a fresh create with default `waitFor` whose `postCreateCommand` exits non-zero after Ready was emitted +- When the user runs `adevcontainer up` +- Then the command exits non-zero +- And the container MUST NOT remain for later reuse as a healthy create + +#### Scenario: resume does not re-wait create-path waitFor + +- Given a stopped container from a prior successful create and default `waitFor` +- When the user runs `adevcontainer up` (start-stopped) or `adevcontainer start` +- Then Ready / open / postAttach are not blocked on onCreate / updateContent / postCreate +- And Ready / open / postAttach MAY occur before this invocation’s `postStartCommand` +- And this invocation’s `postStartCommand` still runs after the container starts + +--- + +### Requirement: userEnvProbe merge + +The CLI MUST admit `userEnvProbe` as an enum of `none`, `interactiveShell`, `loginShell`, or `loginInteractiveShell`. Omitted `userEnvProbe` MUST default to official `loginInteractiveShell`. An unknown value MUST fail resolve with a structured error naming `userEnvProbe`. + +When `userEnvProbe` is not `none`, the CLI MUST probe the **remote connection user’s** shell environment inside the running container and MUST merge the probed variables into the environment of subsequent injected processes on that container: in-container lifecycle execs and `adevcontainer exec`. `none` MUST skip the probe and MUST NOT fail solely because the key is `none`. + +The probe MUST run after the container is running and before the first in-container lifecycle exec of that invocation (and before `adevcontainer exec` injects a process). Probe failure MUST fail the command with a structured error naming `userEnvProbe` and MUST NOT delete the container solely due to that failure. + +#### Scenario: default probe merges into postCreate and exec + +- Given a config that omits `userEnvProbe` and a remote connection user whose login-interactive shell exports a recognizable variable +- When the user runs a fresh `up` that executes `postCreateCommand`, then runs `adevcontainer exec` +- Then both injected processes observe that probed variable + +#### Scenario: none skips probe + +- Given a config with `userEnvProbe` set to `none` +- When the user runs `up` then `adevcontainer exec` +- Then the CLI does not probe the user’s shell environment +- And the command is not failed solely because probing was skipped + +#### Scenario: probe uses remote connection user not containerUser + +- Given `remoteUser` `alice`, `containerUser` `bob`, and `userEnvProbe` other than `none` +- When the probe runs +- Then it probes `alice`’s shell environment, not `bob`’s + +#### Scenario: probe failure keeps the container + +- Given a running or just-started container and a `userEnvProbe` other than `none` that fails +- When the command observes the probe failure +- Then the command exits non-zero with a structured error naming `userEnvProbe` +- And the container is not deleted solely due to that failure + +--- + +### Requirement: shutdownAction admission + +The CLI MUST admit `shutdownAction` as an enum so configs are not rejected solely for this property. For this image/Dockerfile product, omitted `shutdownAction` MUST default to official `stopContainer`. + +- `stopContainer` means `adevcontainer stop` stops the managed container (already required). +- `none` MUST NOT change explicit `adevcontainer stop`: `stop` is still a user command and MUST still stop the container. Last-tool-window-close auto-stop remains out of scope; the CLI MUST NOT claim to observe last-window close. +- `stopCompose` MUST fail closed with a structured error that Compose is unsupported. + +An unknown value MUST fail resolve with a structured error naming `shutdownAction`. + +#### Scenario: stopContainer config still stops on stop + +- Given a running managed container whose config has `shutdownAction` `stopContainer` or omits the key +- When the user runs `adevcontainer stop` for that container +- Then the container is stopped and the command succeeds + +#### Scenario: none does not disable explicit stop + +- Given a running managed container whose config has `shutdownAction` `none` +- When the user runs `adevcontainer stop` for that container +- Then the container is still stopped + +#### Scenario: stopCompose fails closed + +- Given a config with `shutdownAction` `stopCompose` +- When config is resolved +- Then the CLI fails with a structured error indicating Compose is unsupported + +#### Scenario: shutdownAction presence does not fail parse + +- Given an otherwise valid image config with `shutdownAction` `stopContainer` or `none` +- When config is resolved +- Then resolve succeeds + +--- + +### 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. + +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. + +When `start` recovery delegates to `rebuild`, the rebuild create-path already includes config and feature postStart. That recovery MUST NOT run an additional postStart after rebuild returns. + +#### Scenario: volume-mode start remelts feature postStart + +- Given a stopped volume-mode managed container created with a feature that contributed `postStart` (and optional config `postStartCommand`) +- When the user runs `adevcontainer start --name ` +- Then after the container starts, config postStart (when present) then the feature postStart run via container exec +- And the command succeeds if those commands exit 0 + +#### Scenario: up start-stopped remelts feature postStart + +- Given a matching stopped bind-mode container and remeltable feature postStart +- When the user runs `adevcontainer up` +- Then feature postStart runs on this start (not only the original create) +- And onCreate / updateContent / postCreate do not run + +#### Scenario: start recovery via rebuild does not double-run postStart + +- Given `start` fails and recovery delegates to `rebuild` for that container +- When rebuild’s create-path runs `postStartCommand` (config and features) on the new container +- Then the user-visible start-recovery path does not run `postStartCommand` a second time after rebuild returns + +## MODIFIED Requirements + +### Requirement: Lifecycle hook surface + +**Domain:** `lifecycle-hooks` + +The CLI MUST admit and honor these lifecycle properties. Each command property MUST accept a **string**, an **argv array of strings**, or an **object map** of name → string or argv array. Omitted properties and empty object maps MUST be treated as no-ops. + +**Object-map form (official parallel):** each named entry in a stage MUST run concurrently. The stage succeeds only if every entry exits 0. Sequential sorted-by-name MUST NOT be the required behavior. + +In-container hooks that run MUST execute via AppleContainerRuntime **exec** into the running container (not baked into the image), using the **resolved remote connection user** and workspace folder when set — not create-only `containerUser` when `remoteUser` differs. String vs argv invocation MUST keep the existing product rules (`sh -lc` for strings; argv without a shell). `initializeCommand` is the host exception (see **initializeCommand host execution**). + +| Property | Role | +|----------|------| +| `initializeCommand` | Host command at the start of `up` / `clone` / `rebuild` and of a real start when a host workspace exists; volume-mode / clone-origin rebuild with no host workspace still runs on a temporary workspace root that contains the guest config directory/files | +| `onCreateCommand` | Once on fresh create, before content/update and postCreate | +| `updateContentCommand` | On fresh create after `onCreateCommand` (no cloud periodic rerun) | +| `postCreateCommand` | On fresh create after `updateContentCommand` | +| `postStartCommand` | After every successful start of the container: end of fresh create (after postCreate) and start of a previously stopped container (`up` start-stopped and bare `start`, bind and volume) | +| `postAttachCommand` | Admitted; executed per **postAttachCommand policy (CLI-only)** | +| `waitFor` | Enum; default `updateContentCommand`; see **waitFor readiness** | +| `userEnvProbe` | Enum; default `loginInteractiveShell`; see **userEnvProbe merge** | +| `shutdownAction` | Enum; see **shutdownAction admission** | + +Create-path order on fresh `up` / `clone` / `rebuild` remains initialize (host) → onCreate → updateContent → postCreate → postStart, with feature-contributed onCreate / updateContent / postCreate / postStart merged on create-path. Reuse of an already-running container on `up` MUST NOT re-run onCreate / updateContent / postCreate / postStart. `up` reuse MUST still run host `initializeCommand` when a host workspace exists and MUST still follow postAttach policy. + +Create-path hook failure (onCreate, updateContent, postCreate, first-create postStart) MUST fail the command and MUST NOT leave the container for later reuse as a healthy create. Restart-class `postStartCommand` failure MUST fail the command and MUST NOT delete the container. + +#### Scenario: Fresh create runs full hook order + +- Given a config with `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and `postStartCommand` each exiting 0 +- When the user runs `up` and no container exists for the workspace +- Then the CLI runs initialize on the host, then onCreate → updateContent → postCreate → postStart via exec, and `up` succeeds + +#### Scenario: Reuse running skips create-path and postStart + +- Given a matching container already running (matching config hash) and a config with create-path hooks and `postStartCommand` +- When the user runs `up` (no rebuild) +- Then onCreate, updateContent, postCreate, and postStart are not executed +- And postAttach still follows **postAttachCommand policy (CLI-only)** + +#### Scenario: Start stopped runs postStart on up + +- Given a matching container that is stopped and a config with `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and `postStartCommand` +- When the user runs `up` +- Then only resume hooks for a real start run (initialize when a host workspace exists, then postStart; onCreate, updateContent, and postCreate do not run) +- And `up` succeeds if those resume hooks exit 0 + +#### Scenario: Create-path hook failure deletes container + +- Given no existing container and a config whose `onCreateCommand` (or later create-path hook including first-create `postStartCommand`) exits non-zero +- When the user runs `up` +- Then `up` fails with a structured error naming the failing property and exit code, and the container MUST NOT remain for a later reuse as a healthy create + +#### Scenario: Restart postStart failure does not delete container + +- Given a stopped container from a prior successful create and a config whose `postStartCommand` exits non-zero +- When the user runs `up` or `adevcontainer start` +- Then the command fails with a structured error for `postStartCommand` and the container still exists (MUST NOT be deleted solely due to restart postStart failure) + +#### Scenario: Lifecycle command forms + +- Given `postStartCommand` as a string and `onCreateCommand` as an argv array of strings +- When config is resolved +- Then both admit successfully and map using the same shell-vs-argv rules as `postCreateCommand` + +#### Scenario: Lifecycle object-map runs in parallel + +- Given `onCreateCommand` as an object map with two named entries that each exit 0 +- When that stage runs +- Then both named entries run concurrently +- And the stage succeeds only after every entry exits 0 + +#### Scenario: Lifecycle object-map stage fails if any entry fails + +- Given `postStartCommand` as an object map where one named entry exits non-zero +- When that stage runs on a restart path +- Then the stage fails +- And the container is not deleted solely due to that restart failure + +--- + +### Requirement: Start managed container + +**Domain:** `managed-lifecycle` +*(Overrides realized lock at `specs/managed-lifecycle.md` and active `specs/changes/vscode-customizations-up-clone-rebuild/spec.md` **Start managed container** hook lock. Does **not** override that delta’s vscode customizations exclusion on `start`.)* + +The CLI MUST provide `adevcontainer start` that starts a **stopped** managed container. + +**Selection** is unchanged: `--name`, single-eligible auto-select, interactive picker, non-TTY `--name` required; selection set is managed containers only. + +**Runtime behavior** + +- If the selected container is stopped → start it via AppleContainerRuntime after any required host `initializeCommand`. +- If already running → success **no-op** (MUST NOT error solely because it was already running). Already-running MUST NOT run `initializeCommand` or `postStartCommand`. +- MUST NOT re-clone the git URL. +- MUST NOT run the full `up` or `clone` create path (no Features rebuild, no volume re-populate, no onCreate / updateContent / postCreate). + +**Lifecycle hooks on start (replaces the prior lock)** + +| Workspace origin | Real start (stopped → running) | +|------------------|--------------------------------| +| **Bind-mode** | Host `initializeCommand` when a usable stamped host workspace exists; then start; then config `postStartCommand` then remelted feature postStart. Ready / open / postAttach follow **waitFor readiness** and **postAttachCommand policy (CLI-only)** | +| **Volume-mode / clone-origin** | Skip `initializeCommand` with a warning when no host workspace exists; start; then config `postStartCommand` then remelted feature postStart. Ready / open / postAttach follow **waitFor readiness** and **postAttachCommand policy (CLI-only)** | + +`up` start-stopped MUST keep the same resume hook set (initialize when a host workspace exists, then postStart including remelted feature postStart). Bare `start` is no longer runtime-start-only. + +Restart-class hook failure MUST fail `start` and MUST NOT delete the container. When `start` recovery delegates to `rebuild`, rebuild’s create-path already includes postStart; the recovery path MUST NOT double-run postStart after rebuild. + +**Vscode customizations on start (unchanged by this change)** + +- `adevcontainer start` MUST NOT apply `customizations.vscode.settings` or `customizations.vscode.extensions`, with or without `--vscode`. +- Config load on `start` MAY be used for hooks, open, and postAttach. It MUST NOT be used to apply settings or extensions. + +#### Scenario: Start stopped managed container + +- Given a managed container created by clone that is stopped +- When the user runs `adevcontainer start --name ` +- Then the container is running and the command succeeds without re-cloning + +#### Scenario: Start already running is no-op success + +- Given a managed container that is already running +- When the user runs `adevcontainer start --name ` +- Then the command succeeds without changing the container +- And `initializeCommand` and `postStartCommand` do not run + +#### Scenario: Start interactive picker when multiple + +- Given two stopped managed containers and an interactive TTY stdin +- When the user runs `adevcontainer start` without `--name` +- Then the CLI presents an interactive selection UI and starts the chosen container + +#### Scenario: Volume-mode start runs postStart + +- Given a volume-mode managed container with labels from clone and a config that had `postStartCommand` at create time +- When the user runs `adevcontainer start --name ` on a stopped container +- Then the container starts and `postStartCommand` runs via container exec +- And onCreate / updateContent / postCreate do not run + +#### Scenario: Bind-mode start runs postStart + +- Given a stopped bind-mode managed container and a config with `postStartCommand` that exits 0 +- When the user runs `adevcontainer start --name ` +- Then the container starts and `postStartCommand` runs +- And the command succeeds + +#### Scenario: start does not apply vscode customizations + +- Given a managed container whose config has well-formed settings and extensions and whose guest marker is missing or drifted +- When the user runs `adevcontainer start` without or with `--vscode` +- Then the CLI MUST NOT apply those settings or extensions on this path +- And resume hooks still follow this requirement + +--- + +### Requirement: postAttachCommand policy (CLI-only) + +**Domain:** `vscode` + +The CLI MUST parse and admit `postAttachCommand` when present (string, argv array, or object map of name → string|argv — same forms as other hooks) so configs are not rejected solely for this property. Invalid form MUST still fail resolve with a structured error naming `postAttachCommand`. Object-map entries MUST run concurrently per **Lifecycle hook surface**. + +This policy is a **CLI attach model**. The CLI is the supporting tool. Manual IDE UI attach without the CLI remains out of scope. `adevcontainer exec` is **not** attach and MUST NOT run `postAttachCommand`. The product MUST NOT require IDE-confirmed remote ready and MUST NOT wait for VS Code Server fully ready. + +**When postAttach RUNS** + +The CLI MUST execute postAttach when **any** of the following hold after the command’s prior lifecycle steps required by `waitFor` have succeeded: + +1. Successful `adevcontainer up`, `adevcontainer clone`, or `adevcontainer rebuild` (including `up` reuse of an already-running matching container) — the CLI attach at the end of that supporting-tool command. +2. A **real** `adevcontainer start` of a previously stopped container. +3. Already-running `adevcontainer start` **only when** `--vscode` is set **and** best-effort open succeeds — that open is an actual tool attach. + +When `--vscode` is set on a path that already qualifies as CLI attach (items 1–2), postAttach MUST still run **after** the open attempt. If that open **soft-fails**, the CLI MUST still run postAttach (open is best-effort and MUST NOT suppress the CLI attach). When `--vscode` is set and open **succeeds**, postAttach MUST run after that successful open. + +**What runs** + +When the run gate is satisfied, the CLI MUST run config `postAttachCommand` when present, then feature-contributed postAttach commands, using the resolved remote connection user and workspace folder when set. When `remoteUser` is `alice` and `containerUser` is `bob`, postAttach MUST use `alice`. + +**When postAttach is SKIPPED (status line, not executed)** + +- Already-running `start` without a successful `--vscode` open: if any postAttach is present, emit a single stderr skip status and MUST NOT execute postAttach. +- `--vscode` set on already-running `start` and open soft-failed or skipped: MUST NOT execute postAttach; SHOULD emit a skip status that attach open did not succeed. +- No postAttach present: MUST NOT emit a postAttach skip line. + +**Failure policy** + +- If postAttach runs and any postAttach command exits non-zero, the lifecycle command MUST fail (non-zero) with a structured error naming postAttach. +- The CLI MUST NOT delete or stop the container solely due to postAttach failure. On `rebuild`, a non-zero postAttach MUST keep the **new** container and MUST NOT start a recovery session. +- Open soft-fail still MUST NOT fail the lifecycle command **by itself**. +- On postAttach failure, the command MUST follow the existing error path (no success JSON on stdout for `--json` paths). + +**Consistency** + +Presence of `postAttachCommand` alone MUST NOT fail those commands when postAttach is skipped. vscode customizations apply on `start` remains forbidden. + +#### Scenario: postAttach runs at end of up without --vscode + +- Given a valid config with `postAttachCommand` that exits 0 and a successful `up` (fresh, reuse, or start-stopped) +- When the user runs `up` without `--vscode` +- Then the CLI executes `postAttachCommand` via container exec after waitFor is satisfied +- And the command reports lifecycle success when postAttach exits 0 + +#### Scenario: postAttach runs after real start without --vscode + +- Given a stopped managed container, default `waitFor`, and a config with `postAttachCommand` that exits 0 +- When the user runs `adevcontainer start --name ` without `--vscode` +- Then after the real start, once waitFor is satisfied, the CLI executes `postAttachCommand` +- And that MAY be before this invocation’s `postStartCommand` +- And the command succeeds + +#### Scenario: already-running start skips postAttach without successful open + +- Given a managed container that is already running and a config with `postAttachCommand` that would exit non-zero if run +- When the user runs `adevcontainer start --name ` without `--vscode` +- Then `postAttachCommand` does not run +- And stderr includes a one-time skip status +- And the command succeeds + +#### Scenario: already-running start runs postAttach after successful --vscode open + +- Given an already-running managed container, `postAttachCommand` that exits 0, and `--vscode` whose host `code` launch succeeds +- When the user runs `adevcontainer start … --vscode` +- Then after the successful open the CLI executes `postAttachCommand` +- And `initializeCommand` and `postStartCommand` do not run + +#### Scenario: open soft-fail does not suppress CLI-attach postAttach + +- Given a config with `postAttachCommand` present and a successful `up` / `clone` / `rebuild` or real `start` +- When the user runs that command with `--vscode` and open soft-fails +- Then the CLI still executes `postAttachCommand` +- And open soft-fail does not by itself fail the command +- And the managed container is not deleted or stopped solely due to open soft-fail + +#### Scenario: postAttach still runs after successful --vscode open on CLI-attach paths + +- Given a valid config with `postAttachCommand` that exits 0 and a successful container lifecycle on `up` (or equivalently real `start` / `clone` / `rebuild`) +- When the user runs the command with `--vscode` and host `code` launch succeeds +- Then after the successful open the CLI executes `postAttachCommand` +- And the command reports lifecycle success + +#### Scenario: postAttach failure fails command but keeps container + +- Given a CLI-attach path that runs postAttach and `postAttachCommand` exits non-zero +- When the user runs `up` (or `start` / `clone` / `rebuild`) +- Then the command fails with a structured error naming postAttach +- And the managed container still exists and is not deleted or stopped solely due to that failure +- And on rebuild, no recovery session is created +- And no success JSON is emitted on the error path + +#### Scenario: feature postAttach runs on CLI attach + +- Given resolved config with feature-contributed postAttach commands (and optional config `postAttachCommand`) on a CLI-attach path +- When postAttach runs +- Then feature postAttach commands execute via container exec after the config hook when both are present +- And non-zero exit of a feature postAttach fails the command under the same keep-container failure policy as config postAttach + +#### Scenario: exec is not attach + +- Given a running managed container and a config with `postAttachCommand` +- When the user runs `adevcontainer exec` +- Then `postAttachCommand` does not run + +#### Scenario: Invalid postAttach form still fails resolve + +- Given `postAttachCommand` set to a non-string, non-array, non-object value +- When config is resolved +- Then the CLI fails with a structured error naming `postAttachCommand` + +#### Scenario: no skip line when postAttach absent + +- Given a config with no `postAttachCommand` and no feature-contributed postAttach commands +- When the user runs `up` without or with `--vscode` +- Then the CLI MUST NOT emit a postAttach skip status line solely for postAttach + +#### Scenario: postAttach runs as remote connection user not containerUser + +- Given `remoteUser` `alice`, `containerUser` `bob`, and a CLI-attach path that runs postAttach +- When postAttach runs +- Then postAttach exec uses user `alice` + +--- + +### Requirement: Supported property surface (core + lifecycle/runArgs/host) + +**Domain:** `core` +*(Delta — lifecycle bullets only; other surface bullets remain as in the live contract including active vscode-customizations editor-customizations text.)* + +**Lifecycle** + +- `initializeCommand` — string, argv array, or object map; host command per **initializeCommand host execution** +- `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`, `postAttachCommand` — string, argv array, or object map; object-map entries run concurrently; policy per **Lifecycle hook surface** and **postAttachCommand policy (CLI-only)** +- `waitFor` — enum; default `updateContentCommand`; policy per **waitFor readiness** +- `userEnvProbe` — enum; default `loginInteractiveShell`; policy per **userEnvProbe merge** +- `shutdownAction` — enum; default `stopContainer` for this image/Dockerfile product; `stopCompose` fails closed; policy per **shutdownAction admission** + +#### Scenario: Lifecycle / runArgs / hostRequirements property set does not hard-error as unknown + +- Given a config that includes only core supported keys plus the lifecycle properties in this requirement, allowlisted `runArgs`, and `hostRequirements` +- When config is validated +- Then validation does not fail with unsupported-property for those keys + +#### Scenario: initializeCommand waitFor userEnvProbe shutdownAction admit + +- Given a minimal image config that also sets valid `initializeCommand`, `waitFor`, `userEnvProbe`, and `shutdownAction` `stopContainer` +- When config is resolved +- Then resolve succeeds and those fields are available to lifecycle paths + +--- + +### Requirement: Up lifecycle (create, start, reuse) + +**Domain:** `core` +*(Delta — replace the lifecycle hook matrix and postAttach rows. Vscode customizations apply matrix from active `vscode-customizations-up-clone-rebuild` remains in force, including `start` MUST NOT apply. The sentence “Bind start-stopped postStartCommand remains an `up` path only” is superseded.)* + +**Lifecycle hook matrix by path** + +| Path | Lifecycle | +|------|-----------| +| Fresh create (missing) | Host initialize (when a host workspace exists) → onCreate → updateContent → postCreate → postStart; delete container if any create-path hook (onCreate / updateContent / postCreate / first postStart) fails; Ready / open / postAttach wait for `waitFor` (default updateContent) | +| `rebuild ` | Same fresh create-path on the **new** container, including host initialize (volume-mode / clone-origin with no usable host workspace: initialize still runs on a temporary workspace root that contains the guest config directory/files; temp removed after the hook); delete-on-fail applies to the **new** container; recovery offer rules unchanged | +| Reuse running (matching identity) | No onCreate / updateContent / postCreate / postStart; host initialize MUST run when a host workspace exists; postAttach runs as CLI attach | +| Start stopped (`up` or bare `start`) | Host initialize when a host workspace exists; postStart (config then remelted feature postStart); on failure fail the command, do not delete; Ready / open / postAttach follow **waitFor readiness** (this invocation’s postStart only when `waitFor` is `postStartCommand`); postAttach runs as CLI attach | +| Already-running `start` | No initialize / postStart; postAttach only after successful `--vscode` open | +| CLI-attach path (`up` / `clone` / `rebuild` / real `start`) with postAttach present | After waitFor: run config then feature postAttach; `--vscode` open soft-fail MUST NOT skip; on failure fail command, keep container | +| Already-running `start` with postAttach present and no successful `--vscode` open | skip execute; one status line | +| Any path with postAttach absent | no postAttach skip line; no postAttach exec | + +postAttach is **not** part of create-path delete-on-fail. Settings/open soft-fail and postAttach failure MUST NOT enter either recovery session. Customizations apply remains **not** part of create-path delete-on-fail, **not** folded into postAttach, and **not** run on `start`. + +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. + +#### Scenario: Create then reuse + +- Given no existing container for the workspace +- When the user runs `up` twice with the same config +- Then the first run creates and starts a container and prints success JSON including `containerId` and `remoteWorkspaceFolder`, and the second run reuses the running container without error + +#### Scenario: Start stopped container + +- Given a container previously created by `up` that is stopped +- When the user runs `up` +- Then the container is started, resume hooks run, and success JSON is emitted + +#### Scenario: Create then reuse still stable with hooks + +- Given a successful fresh `up` with postStart configured +- When the user runs `up` again while the container is running +- Then the second run reuses without re-running onCreate / updateContent / postCreate / postStart + +#### Scenario: up start-stopped remelts feature postStart + +- Given a matching stopped container and a feature-contributed postStart +- When the user runs `up` +- Then feature postStart runs after the container starts +- And onCreate / updateContent / postCreate do not run + +#### Scenario: up without --vscode still runs postAttach + +- Given a matching running or freshly created container and `postAttachCommand` that exits 0 +- When the user runs `up` without `--vscode` +- Then postAttach runs after waitFor is satisfied + +#### Scenario: rebuild hook matrix row applies + +- 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 + +--- + +### Requirement: Clone lifecycle hooks and temp cleanup + +**Domain:** `clone` + +**Lifecycle (clone fresh create)** + +At the start of `clone`, when a host checkout exists, the CLI MUST run host `initializeCommand` per **initializeCommand host execution**. After successful populate, `clone` MUST run create-path lifecycle hooks with the **same matrix as `up` fresh create**: + +`onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` + +- In-container hooks run via AppleContainerRuntime exec (not baked into the image). +- Non-zero exit of any create-path hook MUST fail `clone` and MUST delete the container **and** the workspace volume before returning failure. +- `postAttachCommand` follows **postAttachCommand policy (CLI-only)** (CLI attach at the end of successful `clone`; not `--vscode`-gated; failure fails `clone` but MUST NOT delete container/volume solely due to postAttach failure). +- `waitFor` applies as on `up` fresh create. + +**Temp cleanup** is unchanged: config-fetch temps deleted on success and failure; temp-deletion failure warns only. + +#### Scenario: Create-path hooks run after populate + +- Given a config with `postCreateCommand` that exits 0 +- When clone completes create, start, and populate successfully +- Then create-path hooks run in order and clone reports success + +#### Scenario: clone runs postAttach without --vscode + +- Given a successful clone populate and create-path hooks and `postAttachCommand` that exits 0 +- When the user runs `clone` without `--vscode` +- Then the CLI executes `postAttachCommand` +- And clone reports success + +#### Scenario: Temp dirs always cleaned up + +- Given clone runs to success or to a mid-flow structured failure after temps were created +- When the command returns +- Then config-fetch temp directories are removed (or a stderr warning is emitted if removal failed) + +#### Scenario: Hook failure deletes container and workspace volume + +- Given populate succeeded and `postCreateCommand` exits non-zero +- When clone runs +- Then clone fails structured, the managed dev container is deleted, the workspace `*-ws` volume is deleted, and temps are cleaned up + +--- + +### Requirement: VS Code attach acceptance + +**Domain:** `vscode` +*(Delta — replace CLI attach hook bullet 3. Manual attach, optional open, and customizations-apply bullets stay as in the live contract including active vscode-customizations.)* + +3. **CLI attach hook for postAttach:** The CLI is the supporting tool. `postAttachCommand` runs per **postAttachCommand policy (CLI-only)** — at the end of successful `up` / `clone` / `rebuild`, after a real `start`, and on already-running `start` only after successful `--vscode` open. A successful best-effort open is an additional tool attach, not the sole gate, and MUST NOT be required for CLI-attach paths. This is an approximation of IDE attach, not confirmation that the remote session is fully ready. + +#### Scenario: Running container is attachable target + +- Given a successful `up` (or `clone`) +- When the user lists/inspects containers via the CLI +- Then the managed dev container is identifiable for manual VS Code attach + +#### Scenario: Optional open does not replace manual attach + +- Given a successful lifecycle without or with `--vscode` +- When the user chooses not to rely on automatic open (flag omitted, or open soft-failed) +- Then list/inspect still expose enough identity for manual experimental attach +- And the CLI documentation MUST NOT state that full Dev Containers extension parity is provided + +--- + +### Requirement: Optional `--vscode` flag on up, start, clone, and rebuild + +**Domain:** `vscode` +*(Delta — `--vscode` remains best-effort open; postAttach is no longer gated on the flag except for already-running `start`. Customizations apply on `start` remains forbidden.)* + +When `--vscode` is **absent**, those commands MUST NOT invoke a host VS Code open. When `--vscode` is **present**, after the command’s container lifecycle has reached the `waitFor` connection point and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder**. postAttach after that open is specified under **postAttachCommand policy (CLI-only)**. + +`--vscode` MUST NOT gate settings apply or extensions apply. On `start`, the flag still requests open (and postAttach only when open succeeds on an already-running container); `start` MUST NOT apply customizations. On CLI-attach paths, omitting `--vscode` MUST NOT skip postAttach. + +#### Scenario: --vscode still only gates open not apply on up + +- Given a successful `up` create-path with well-formed settings and extensions and a config that also has `postAttachCommand` +- When the user runs `up` **without** `--vscode` +- Then settings and extensions apply still run per the apply requirements +- And the CLI MUST NOT invoke a host VS Code open +- And postAttach MUST execute as CLI attach + +#### Scenario: --vscode on already-running start opens without applying customizations + +- Given a managed container that is already running and a config with settings, extensions, and `postAttachCommand` +- When the user runs `start --vscode` and host `code` launch succeeds +- Then after start success the CLI attempts to open a new VS Code window attached to that container +- And postAttach runs after that successful open +- And the CLI MUST NOT apply settings or extensions on that `start` invocation +- And `postStartCommand` does not run + +#### Scenario: without --vscode behavior unchanged for open + +- Given any valid `up`, `start`, `clone`, or `rebuild` invocation +- When the user omits `--vscode` +- Then the CLI MUST NOT invoke a host VS Code open as part of that command +- And manual attach remains valid + +--- + +### Requirement: VS Code best-effort open + +**Domain:** `vscode` +*(Delta — open soft-fail MUST NOT suppress CLI-attach postAttach. Other open mechanics stay.)* + +**Soft-fail (MUST):** + +- If no usable VS Code CLI (`code`) is found, or the open/launch fails for any reason, the CLI MUST emit a clear warning on stderr, MUST NOT change the lifecycle command’s success exit solely because open failed, and MUST NOT tear down or alter the container as a consequence of open failure. +- On a path that would otherwise run postAttach as CLI attach (`up` / `clone` / `rebuild` / real `start`), open soft-fail MUST NOT prevent postAttach. +- On already-running `start`, open soft-fail MUST NOT by itself execute postAttach (there was no CLI attach and no successful tool open). + +Successful host `code` launch remains a CLI-initiated attach approximation. The CLI MUST NOT wait for VS Code Server fully ready. Detecting manual UI attach is out of scope. + +#### Scenario: soft-fail when code CLI missing on CLI-attach path + +- Given lifecycle would otherwise succeed on `up` and `--vscode` is set and `postAttachCommand` exits 0 +- When no usable `code` executable is discoverable on the host +- Then the command still attempts `postAttachCommand` +- And a stderr warning indicates that VS Code open was skipped or failed because `code` was not found +- And the managed container remains running / created as the lifecycle commanded + +#### Scenario: soft-fail when launch fails on already-running start + +- Given an already-running container, `--vscode` set, and a discoverable `code` that fails when invoked for open +- When open/launch returns failure +- Then the lifecycle command still reports success +- And a stderr warning indicates the open failure +- And `postAttachCommand` MUST NOT execute +- And the managed container is not deleted or stopped solely due to that failure + +--- + +### Requirement: Merge feature metadata into create and lifecycle + +**Domain:** `features` +*(Delta — lifecycle-hooks contribution row only.)* + +| Contribution | Merge behavior | +|--------------|----------------| +| lifecycle hooks contributed by features | Appended/merged into the create-path exec order after start (installs already in derived image); same string/argv/object-map forms and failure/delete-on-fail policy as config hooks for create-path failures. Feature `postStart` (and feature `postAttach` when postAttach runs) MUST remelt on resume per **Feature postStart remelt on resume** and **postAttachCommand policy (CLI-only)**. Feature onCreate / updateContent / postCreate MUST NOT run on resume. | + +#### Scenario: Feature lifecycle hooks run on fresh create via exec + +- Given feature metadata contributing a post-create-style lifecycle command and a fresh create path +- When `up` succeeds through create +- Then the contributed hook runs via runtime exec after start (features already installed in the derived image), and non-zero exit fails `up` under create-path policy + +#### Scenario: Feature postStart remelts on start + +- Given feature metadata contributing `postStart` and a stopped managed container from a prior successful create +- When the user runs `adevcontainer start` or `up` start-stopped +- Then the contributed postStart runs via runtime exec on this start + +#### Scenario: start with unreadable config still runs metadata postStart + +- Given a stopped managed container whose stamped config cannot be read and whose image `devcontainer.metadata` contributes `postStart` +- When the user runs `adevcontainer start --name ` +- Then after the container starts, feature-only postStart runs via container exec (`failKeepContainer`) +- And onCreate / updateContent / postCreate do not run +- And vscode customizations are not applied + +#### Scenario: start with unreadable config still runs metadata postAttach on CLI attach + +- Given a stopped managed container whose stamped config cannot be read and whose image `devcontainer.metadata` contributes `postAttach` +- When the user runs a real `adevcontainer start` (CLI-attach gate) +- Then feature-only postAttach runs via container exec (`failKeepContainer`) + +#### Scenario: derived-image LABEL includes base-image postStart/postAttach after Features build + +- Given a base image whose `devcontainer.metadata` contributes `postStart` / `postAttach` and a feature that also contributes those hooks +- When Features builds a derived image +- Then the derived `LABEL devcontainer.metadata` includes both the base-image and feature hooks + +#### Scenario: no-features up runs image-metadata postCreate/postStart + +- Given a config with empty `features` and a base image whose `devcontainer.metadata` contributes `onCreate` / `updateContent` / `postCreate` / `postStart` / `postAttach` +- When the user runs a fresh `up` +- Then those image-metadata hooks run via container exec on the create path (and postAttach as CLI attach) +- And Features `container build` does not run + +#### 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) diff --git a/specs/changes/align-official-lifecycle/tasks.md b/specs/changes/align-official-lifecycle/tasks.md new file mode 100644 index 0000000..919d6d4 --- /dev/null +++ b/specs/changes/align-official-lifecycle/tasks.md @@ -0,0 +1,341 @@ +# Tasks: align-official-lifecycle + +Spec ref: `specs/changes/align-official-lifecycle/` +Base contract: union of `specs/.md` plus active `specs/changes/vscode-customizations-up-clone-rebuild/spec.md` and this change’s `spec.md` (this change supersedes that delta’s start hook lock and `--vscode`-gated postAttach rows; it does **not** supersede “`start` MUST NOT apply vscode customizations”) +Tests: `Tests/adevcontainerTests/` (MiniTest; `swift run adevcontainerTests`) +Package root: repository root + +Test-first: write or flip the test, confirm it fails, then implement. Mock runtime/guest/open so the default suite needs no real container or VS Code. Keep string → `sh -lc` and argv-without-shell. Do **not** apply vscode settings/extensions on `start`. Do **not** add cloud `updateContent`, Compose, an IDE attach listener, or last-window-close auto-stop. + +## 1. Failing tests — admit official keys + +- [x] 1.1 Flip `unknownPropertyFails` off `shutdownAction` (use a truly unknown key). Add admit + resolve tests: valid `initializeCommand` (string / argv / object-map; empty object-map → nil), `waitFor` enum + omitted default `updateContentCommand`, `userEnvProbe` enum + omitted default `loginInteractiveShell`, `shutdownAction` `stopContainer` / omitted default `stopContainer` / `none`. Unknown `waitFor` / `userEnvProbe` / `shutdownAction` and invalid `initializeCommand` form fail resolve with a structured error naming that property. `stopCompose` fails closed (Compose unsupported). Combined core + lifecycle + allowlisted `runArgs` + `hostRequirements` must not hard-error as unknown (path: `Tests/adevcontainerTests/AllUnitTests.swift`) + +## Checkpoint + +- [x] verify **shutdownAction presence does not fail parse** encoded as a failing test (today `unknownPropertyFails` rejects it) +- [x] verify **initializeCommand waitFor userEnvProbe shutdownAction admit** encoded as a failing test +- [x] verify **Lifecycle / runArgs / hostRequirements property set does not hard-error as unknown** encoded +- [x] verify **stopCompose fails closed** encoded +- [x] verify unknown `waitFor` / `userEnvProbe` / `shutdownAction` and invalid `initializeCommand` form fail resolve naming the property +- [x] verify **Invalid postAttach form still fails resolve** remains encoded (`invalidPostAttachFormFails`) +- [x] verify **Lifecycle command forms** remains encoded (`lifecycleCommandFormsParse`) + +## 2. Implement admission + resolved model + +- [x] 2.1 Add `initializeCommand`, `waitFor`, `userEnvProbe`, and `shutdownAction` to the supported-key set (path: `Sources/ADevContainerLib/Config/ConfigAdmissions.swift`) +- [x] 2.2 [P] Add resolved fields + parse/defaults: `initializeCommand` via `LifecycleCommand.parse`; `waitFor` / `userEnvProbe` / `shutdownAction` enums with official defaults; unknown / `stopCompose` fail closed naming the property. Include `initializeCommand`, `waitFor`, and `userEnvProbe` in `hashMaterial` (omit `shutdownAction`, same spirit as postAttach). Do not change `LifecycleCommand.execArguments` shell rules (path: `Sources/ADevContainerLib/Config/DevContainerConfig.swift`) +- [x] 2.3 Wire the new keys through `buildResolved` so they are available to lifecycle paths (path: `Sources/ADevContainerLib/Config/ConfigResolver.swift`) + +## Checkpoint + +- [x] verify **shutdownAction presence does not fail parse** +- [x] verify **initializeCommand waitFor userEnvProbe shutdownAction admit** +- [x] verify **Lifecycle / runArgs / hostRequirements property set does not hard-error as unknown** +- [x] verify **stopCompose fails closed** +- [x] verify **Lifecycle command forms** (regression) +- [x] verify **Invalid postAttach form still fails resolve** (regression) + +## 3. Failing tests — object-map parallel + +- [x] 3.1 Add `LifecycleRunner` tests: two named `onCreateCommand` entries that each exit 0 must overlap in flight (latch: first exec blocks until the second starts); stage succeeds only after both exit 0. Restart-path `postStartCommand` object-map with one non-zero entry fails the stage and MUST NOT delete (path: `Tests/adevcontainerTests/AllUnitTests.swift`) + +## Checkpoint + +- [x] verify **Lifecycle object-map runs in parallel** encoded as a failing test (today `runIfPresent` is sequential sorted-by-name) +- [x] verify **Lifecycle object-map stage fails if any entry fails** encoded as a failing test + +## 4. Implement parallel object-map + +- [x] 4.1 Run `.parallel` named entries concurrently; stage succeeds only if every entry exits 0. Keep leaf string/argv invocation unchanged. Apply the same policy to host `initializeCommand` when that runner is added (path: `Sources/ADevContainerLib/Commands/LifecycleRunner.swift`) + +## Checkpoint + +- [x] verify **Lifecycle object-map runs in parallel** +- [x] verify **Lifecycle object-map stage fails if any entry fails** + +## 5. Failing tests — initializeCommand host execution + +- [x] 5.1 Fresh `up`: `initializeCommand` that writes a host marker (or is observed via a host-process seam) runs on the workspace **before** `create`; create-path exec order remains onCreate → updateContent → postCreate → postStart. `up` reuse of a running bind container still runs host initialize and MUST NOT exec onCreate / updateContent / postCreate / postStart. `up` start-stopped runs host initialize then postStart only. Failing initialize on a missing container fails naming `initializeCommand` and MUST NOT `create` (path: `Tests/adevcontainerTests/AllCommandTests.swift`) +- [x] 5.2 [P] Bind `start` of a stopped container with stamped `local_folder` runs host initialize **before** runtime `start`; already-running `start` does not. Failing initialize on a stopped container fails naming `initializeCommand` and leaves it stopped (no `start` call) (path: `Tests/adevcontainerTests/VSCodeOpenTests.swift`) +- [x] 5.3 [P] Volume-mode `start` with no usable host workspace skips initialize, warns that the host command cannot run, and still starts. Clone with `initializeCommand` runs it on the config-fetch / retained-checkout host directory before create (path: `Tests/adevcontainerTests/CloneInVolumeTests.swift`) +- [x] 5.4 [P] Rebuild create-path runs host initialize on the stamped / retained host path **before** creating the new container; first create-path hook failure still deletes only the new container (path: `Tests/adevcontainerTests/RebuildCommandPhaseTests.swift`) + +## Checkpoint + +- [x] verify **up runs initializeCommand on the host before create** encoded as a failing test +- [x] verify **clone runs initializeCommand on the host checkout** encoded as a failing test +- [x] verify **real bind start runs initializeCommand from stamped host path** encoded as a failing test +- [x] verify **volume-mode start without host workspace skips initializeCommand** encoded as a failing test +- [x] verify **already-running start does not run initializeCommand** encoded as a failing test +- [x] verify **up reuse still runs initializeCommand on the host** encoded as a failing test +- [x] verify **initializeCommand failure blocks create** encoded as a failing test +- [x] verify **initializeCommand failure leaves a stopped container stopped** encoded as a failing test +- [x] verify **rebuild hook matrix row applies** includes host initialize before the new container’s create-path hooks + +## 6. Implement initializeCommand + +- [x] 6.1 Add a host-only initialize runner (cwd = host workspace; string/`argv`/object-map; object-map concurrent; failure → structured error naming `initializeCommand`; not delete-on-fail). Inject a `ProcessRunning` seam so tests do not need a live shell side effect. Skip + warn when the caller reports no usable host workspace (path: `Sources/ADevContainerLib/Commands/LifecycleRunner.swift`) +- [x] 6.2 Run host initialize at the start of `up` (fresh, reuse, start-stopped) when `resolved.workspacePath` exists; on initialize failure do not `create`, and on reuse do not stop/delete the running container (path: `Sources/ADevContainerLib/Commands/UpCommand.swift`) +- [x] 6.3 [P] Run host initialize on the config-fetch or retained-checkout directory before create when that host path exists (path: `Sources/ADevContainerLib/Commands/CloneCommand.swift`) +- [x] 6.4 [P] Run host initialize on the stamped bind folder or retained clone checkout before creating the new container (path: `Sources/ADevContainerLib/Commands/RebuildCommand.swift`) +- [x] 6.5 [P] On a real start, load config from labels (hooks/open/postAttach only — never settings/extensions). Run host initialize from stamped `local_folder` / config **before** `runtime.start` when a host workspace exists; volume-mode with no host path skips + warns. Already-running MUST NOT run initialize. Initialize failure MUST NOT start the stopped container (path: `Sources/ADevContainerLib/Commands/StartCommand.swift`) + +## Checkpoint + +- [x] verify **up runs initializeCommand on the host before create** +- [x] verify **clone runs initializeCommand on the host checkout** +- [x] verify **real bind start runs initializeCommand from stamped host path** +- [x] verify **volume-mode start without host workspace skips initializeCommand** +- [x] verify **already-running start does not run initializeCommand** +- [x] verify **up reuse still runs initializeCommand on the host** +- [x] verify **initializeCommand failure blocks create** +- [x] verify **initializeCommand failure leaves a stopped container stopped** +- [x] verify **Fresh create runs full hook order** (initialize on host, then onCreate → updateContent → postCreate → postStart) +- [x] verify **rebuild hook matrix row applies** + +## 7. Failing tests — waitFor readiness + +- [x] 7.1 Default (omitted) `waitFor`: capture Ready on stderr after `updateContentCommand` succeeds and **before** `postCreateCommand`’s exec returns (latch the postCreate handler). `waitFor` `postCreateCommand` MUST NOT emit Ready / open / postAttach until postCreate finishes; postStart is still initiated after postCreate. `--json`: success JSON MUST NOT appear before updateContent succeeds and MAY appear before postCreate finishes; process must not return 0 until remaining hooks succeed. After Ready, a failing postCreate still exits non-zero and deletes the new container. Resume (`up` start-stopped and `start`) with default `waitFor` MUST NOT block Ready / open / postAttach on onCreate / updateContent / postCreate; this invocation’s postStart still runs (path: `Tests/adevcontainerTests/AllCommandTests.swift`) + +## Checkpoint + +- [x] verify **default waitFor allows Ready before postCreate** encoded as a failing test +- [x] verify **waitFor postCreateCommand delays Ready until postCreate** encoded as a failing test +- [x] verify **success JSON waits for waitFor not for later hooks** encoded as a failing test +- [x] verify **background create-path hook failure still deletes** encoded as a failing test +- [x] verify **resume does not re-wait create-path waitFor** encoded as a failing test + +## 8. Implement waitFor + +- [x] 8.1 Split create-path so Ready / optional open / postAttach can run once the named stage inclusive has succeeded, while later create-path hooks continue. Process still waits for remaining hooks before returning so delete-on-fail and the exit code stay correct. First-create postStart still starts after postCreate even when `waitFor` is `updateContentCommand`. Resume treats create-path stages as already satisfied; only `waitFor` `postStartCommand` waits on this invocation’s postStart. Restart-class failure MUST NOT delete (path: `Sources/ADevContainerLib/Commands/LifecycleRunner.swift`) +- [x] 8.2 Emit Ready (and, when `--json`, success JSON) at the waitFor point from the command path — `AdevcontainerMain` currently prints JSON only after `UpCommand.run` returns; do not wait until process exit to emit, and do not emit a later success JSON if a background hook then fails. Open MAY happen after waitFor and MUST NOT wait for later background hooks solely to open (path: `Sources/ADevContainerLib/Commands/UpCommand.swift`) +- [x] 8.3 [P] Apply the same waitFor / Ready / JSON / open / postAttach split on clone fresh create (path: `Sources/ADevContainerLib/Commands/CloneCommand.swift`) +- [x] 8.4 [P] Apply the same waitFor / Ready / JSON / open / postAttach split on rebuild’s new-container create-path (path: `Sources/ADevContainerLib/Commands/RebuildCommand.swift`) +- [x] 8.5 [P] Resume `start`: do not re-wait create-path `waitFor`; if `waitFor` is `postStartCommand`, hold Ready / open / postAttach until this start’s postStart finishes (path: `Sources/ADevContainerLib/Commands/StartCommand.swift`) + +## Checkpoint + +- [x] verify **default waitFor allows Ready before postCreate** +- [x] verify **waitFor postCreateCommand delays Ready until postCreate** +- [x] verify **success JSON waits for waitFor not for later hooks** +- [x] verify **background create-path hook failure still deletes** +- [x] verify **resume does not re-wait create-path waitFor** +- [x] verify **Create-path hook failure deletes container** (regression, including first-create postStart) +- [x] verify **Create then reuse** still emits success JSON with `containerId` and `remoteWorkspaceFolder` + +## 9. Failing tests — userEnvProbe + +- [x] 9.1 Omitted `userEnvProbe`: mock the remote user’s login-interactive probe to export a recognizable variable; `postCreateCommand` exec and a following `adevcontainer exec` both see it merged. `userEnvProbe` `none` performs no probe exec and does not fail. Probe exec uses `remoteUser` `alice`, not `containerUser` `bob`. Probe failure exits non-zero naming `userEnvProbe` and MUST NOT `delete` (path: `Tests/adevcontainerTests/AllCommandTests.swift`) + +## Checkpoint + +- [x] verify **default probe merges into postCreate and exec** encoded as a failing test +- [x] verify **none skips probe** encoded as a failing test +- [x] verify **probe uses remote connection user not containerUser** encoded as a failing test +- [x] verify **probe failure keeps the container** encoded as a failing test +- [x] verify **exec is not attach** encoded (exec MUST NOT run `postAttachCommand`) + +## 10. Implement userEnvProbe + +- [x] 10.1 After the container is running and before the first in-container lifecycle exec of that invocation, probe the remote connection user’s shell when `userEnvProbe` is not `none`; merge probed variables into subsequent lifecycle exec env. `none` skips. Probe failure → structured error naming `userEnvProbe`, keep container (path: `Sources/ADevContainerLib/Commands/LifecycleRunner.swift`) +- [x] 10.2 [P] Before injecting `adevcontainer exec`, probe (unless `none`) and merge into that exec’s env. Exec is not attach — do not run postAttach (path: `Sources/ADevContainerLib/Commands/ExecCommand.swift`) + +## Checkpoint + +- [x] verify **default probe merges into postCreate and exec** +- [x] verify **none skips probe** +- [x] verify **probe uses remote connection user not containerUser** +- [x] verify **probe failure keeps the container** +- [x] verify **exec is not attach** + +## 11. Failing tests — shutdownAction stop behavior + +- [x] 11.1 `shutdownAction` `stopContainer` or omitted: `adevcontainer stop` still stops. `shutdownAction` `none`: explicit `stop` still stops (path: `Tests/adevcontainerTests/AllCommandTests.swift`) + +## Checkpoint + +- [x] verify **stopContainer config still stops on stop** encoded (may already pass once admit lands; keep as regression) +- [x] verify **none does not disable explicit stop** encoded as a failing-or-regression test + +## 12. Implement shutdownAction stop behavior + +- [x] 12.1 Keep explicit `stop` stopping the managed container regardless of `shutdownAction` `stopContainer` / omitted / `none`. Do not claim last-window-close auto-stop (path: `Sources/ADevContainerLib/Commands/StopCommand.swift`) + +## Checkpoint + +- [x] verify **stopContainer config still stops on stop** +- [x] verify **none does not disable explicit stop** + +## 13. Failing tests — start postStart + feature remelt + +- [x] 13.1 Flip `startStoppedManagedNoHooks`: volume-mode real start with config `postStartCommand` (and remeltable feature postStart via image `devcontainer.metadata`) MUST exec config then feature postStart; MUST NOT exec onCreate / updateContent / postCreate; MUST NOT `create`. Keep `startAlreadyRunningNoOp` (no `start`, no initialize, no postStart). Keep picker coverage (path: `Tests/adevcontainerTests/CloneInVolumeTests.swift`) +- [x] 13.2 [P] Bind `start` of a stopped container runs config `postStartCommand` after runtime start. Already-running `start` does not run postStart. Restart postStart non-zero fails `start` and MUST NOT delete. `start` still MUST NOT apply settings/extensions (path: `Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift`) +- [x] 13.3 [P] `up` start-stopped remelts feature postStart from image metadata (Features not re-run); onCreate / updateContent / postCreate do not run. Restart postStart failure still keeps the container (`restartPostStartFailureDoesNotDelete`) (path: `Tests/adevcontainerTests/AllCommandTests.swift`) +- [x] 13.4 [P] When `start` recovery delegates to `rebuild`, rebuild’s create-path runs postStart; StartCommand MUST NOT exec postStart again after `rebuildOverride` / `RebuildCommand.run` returns (path: `Tests/adevcontainerTests/StartCommandRecoveryTests.swift`) +- [x] 13.5 [P] `PostAttachConfigLoader` / metadata remelt populates `featurePostStartCommands` the same way it already remelts postAttach (`configReaderTests` metadata case) (path: `Tests/adevcontainerTests/ConfigReaderTests.swift`) + +## Checkpoint + +- [x] verify **Volume-mode start runs postStart** encoded (replaces **Volume-mode start runs no hooks**) +- [x] verify **volume-mode start remelts feature postStart** encoded as a failing test +- [x] verify **Bind-mode start runs postStart** encoded as a failing test +- [x] verify **up start-stopped remelts feature postStart** encoded as a failing test +- [x] verify **Feature postStart remelts on start** encoded as a failing test +- [x] verify **start recovery via rebuild does not double-run postStart** encoded as a failing test +- [x] verify **Start stopped managed container** / **Start already running is no-op success** / **Start interactive picker when multiple** remain encoded +- [x] verify **start does not apply vscode customizations** remains encoded +- [x] verify **Start stopped runs postStart on up** / **Restart postStart failure does not delete container** remain encoded + +## 14. Implement start postStart + feature remelt + +- [x] 14.1 Remelt feature `postStart` (not only postAttach) from image/container `devcontainer.metadata` into `featurePostStartCommands` on resume loads (path: `Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift`) +- [x] 14.2 After a successful real start: remelt feature postStart, run config then feature postStart (`failKeepContainer`), then postAttach per the CLI-attach gate. Already-running: no initialize / postStart. Recovery that delegates to rebuild returns without a second postStart. Config load remains hooks/open/postAttach only — never settings/extensions apply (path: `Sources/ADevContainerLib/Commands/StartCommand.swift`) +- [x] 14.3 [P] On `up` start-stopped, remelt feature postStart into the reuse config before `runRestartPostStart` (path: `Sources/ADevContainerLib/Commands/UpCommand.swift`) + +## Checkpoint + +- [x] verify **Volume-mode start runs postStart** +- [x] verify **volume-mode start remelts feature postStart** +- [x] verify **Bind-mode start runs postStart** +- [x] verify **up start-stopped remelts feature postStart** +- [x] verify **Feature postStart remelts on start** +- [x] verify **start recovery via rebuild does not double-run postStart** +- [x] verify **Start stopped managed container** +- [x] verify **Start already running is no-op success** (no initialize, no postStart) +- [x] verify **Start interactive picker when multiple** +- [x] verify **start does not apply vscode customizations** +- [x] verify **Start stopped runs postStart on up** +- [x] verify **Restart postStart failure does not delete container** +- [x] verify **Reuse running skips create-path and postStart** (initialize + postAttach still allowed) +- [x] verify **Create then reuse still stable with hooks** +- [x] verify **Feature lifecycle hooks run on fresh create via exec** (regression) + +## 15. Failing tests — postAttach CLI attach + +- [x] 15.1 Flip `upPostAttachSkippedWithoutVSCode`, `upPostAttachSkippedWhenOpenSoftFails`, and `postAttachAdmittedButNotRunOnUp`: CLI-attach `up` runs postAttach after waitFor even without `--vscode`; open soft-fail MUST NOT skip; absent postAttach emits no skip line (`upNoPostAttachWhenAbsent`). Flip real-start `startPostAttachSkippedWithoutVSCode` / `startPostAttachSkippedWhenOpenSoftFails` to **run** postAttach. Add already-running `start` without successful `--vscode` open: skip + one status line; already-running `start --vscode` success: postAttach after open, no initialize / postStart; already-running open soft-fail: success, warn, no postAttach. Keep fail-keep / no success JSON. Feature postAttach after config. CLI-attach postAttach exec uses `remoteUser` `alice`, not `containerUser` `bob` (path: `Tests/adevcontainerTests/VSCodeOpenTests.swift`) +- [x] 15.2 [P] Flip `startPostAttachSkippedWithoutVSCode` the same way (real start runs postAttach; still no settings/extensions). Keep `startWithVSCodeOpensWithoutApplyingCustomizations` and already-running `startWithoutVSCodeDoesNotInstallExtensions` (no postStart / postAttach on already-running without open) (path: `Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift`) +- [x] 15.3 [P] Flip rebuild-without-`--vscode` and rebuild-open-soft-fail so postAttach **runs** (CLI attach). Keep postAttach failure fail-keep on the new container / no recovery session. Rebuild `--vscode` success still runs postAttach after open (path: `Tests/adevcontainerTests/RebuildCommandPhaseTests.swift`) +- [x] 15.4 [P] Clone without `--vscode` runs postAttach after waitFor; clone open soft-fail still runs postAttach; temp cleanup and create-path hook delete-container+volume unchanged (path: `Tests/adevcontainerTests/CloneInVolumeTests.swift`) + +## Checkpoint + +- [x] verify **postAttach runs at end of up without --vscode** encoded as a failing test +- [x] verify **up without --vscode still runs postAttach** encoded as a failing test +- [x] verify **postAttach runs after real start without --vscode** encoded as a failing test +- [x] verify **already-running start skips postAttach without successful open** remains encoded (move off the real-start fixtures) +- [x] verify **already-running start runs postAttach after successful --vscode open** encoded +- [x] verify **open soft-fail does not suppress CLI-attach postAttach** encoded as a failing test +- [x] verify **postAttach still runs after successful --vscode open on CLI-attach paths** remains encoded +- [x] verify **postAttach failure fails command but keeps container** remains encoded +- [x] verify **feature postAttach runs on CLI attach** encoded +- [x] verify **no skip line when postAttach absent** remains encoded +- [x] verify **clone runs postAttach without --vscode** encoded as a failing test +- [x] verify **soft-fail when code CLI missing on CLI-attach path** encoded as a failing test +- [x] verify **soft-fail when launch fails on already-running start** encoded + +## 16. Implement postAttach CLI attach + +- [x] 16.1 Replace the open-only gate: CLI-attach paths (`up` / `clone` / `rebuild` / real `start`) run config then feature postAttach after waitFor; `--vscode` open (success or soft-fail) MUST NOT skip. Already-running `start`: run only after successful open; otherwise one skip status when postAttach is present; no skip line when absent. Soft-fail open on already-running MUST NOT execute postAttach. Failure: fail-keep, no success JSON (path: `Sources/ADevContainerLib/Commands/LifecycleRunner.swift`) +- [x] 16.2 [P] Treat every `up` finish path (fresh, reuse, start-stopped) as CLI attach for postAttach (path: `Sources/ADevContainerLib/Commands/UpCommand.swift`) +- [x] 16.3 [P] Treat successful clone as CLI attach for postAttach (not `--vscode`-gated) (path: `Sources/ADevContainerLib/Commands/CloneCommand.swift`) +- [x] 16.4 [P] Treat rebuild’s new container as CLI attach for postAttach; non-zero postAttach keeps the new container and MUST NOT start recovery (path: `Sources/ADevContainerLib/Commands/RebuildCommand.swift`) +- [x] 16.5 [P] Real start: postAttach after postStart (waitFor-aware) even without `--vscode`; already-running only after successful open. Still never apply settings/extensions (path: `Sources/ADevContainerLib/Commands/StartCommand.swift`) + +## Checkpoint + +- [x] verify **postAttach runs at end of up without --vscode** +- [x] verify **up without --vscode still runs postAttach** +- [x] verify **postAttach runs after real start without --vscode** +- [x] verify **already-running start skips postAttach without successful open** +- [x] verify **already-running start runs postAttach after successful --vscode open** +- [x] verify **open soft-fail does not suppress CLI-attach postAttach** +- [x] verify **postAttach still runs after successful --vscode open on CLI-attach paths** +- [x] verify **postAttach failure fails command but keeps container** +- [x] verify **feature postAttach runs on CLI attach** +- [x] verify **no skip line when postAttach absent** +- [x] verify **postAttach runs as remote connection user not containerUser** +- [x] verify **clone runs postAttach without --vscode** +- [x] verify **--vscode still only gates open not apply on up** +- [x] verify **--vscode on already-running start opens without applying customizations** +- [x] verify **without --vscode behavior unchanged for open** +- [x] verify **soft-fail when code CLI missing on CLI-attach path** +- [x] verify **soft-fail when launch fails on already-running start** + +## 17. Help and remaining matrix + +- [x] 17.1 Rewrite usage + `up` / `start` / `clone` / `rebuild` help: `start` runs initialize (when a host workspace exists) + postStart + remelted feature postStart on a real start; already-running is a no-op for those hooks; `start` still does not apply settings/extensions; `--vscode` is best-effort open (and postAttach only for already-running `start`); postAttach is CLI attach on `up` / `clone` / `rebuild` / real `start`. MUST NOT claim full Dev Containers extension parity or last-window-close auto-stop (path: `Sources/ADevContainerLib/Support/CommandSurface.swift`) +- [x] 17.2 [P] Extend `usageAndCommandHelpDoNotGateApplyOnVSCode` so help no longer says start does not run postStart or that postAttach runs only after successful open / is `--vscode`-gated, while start still MUST say it does not apply settings or extensions (path: `Tests/adevcontainerTests/VSCodeCustomizationsCommandTests.swift`) +- [x] 17.3 [P] Confirm existing clone populate-hook order, temp cleanup, and hook-failure delete container+volume still pass with initialize + waitFor + ungated postAttach (path: `Tests/adevcontainerTests/CloneInVolumeTests.swift`) + +## Checkpoint + +- [x] verify **Create-path hooks run after populate** +- [x] verify **Temp dirs always cleaned up** +- [x] verify **Hook failure deletes container and workspace volume** +- [x] verify **Running container is attachable target** +- [x] verify **Optional open does not replace manual attach** (docs MUST NOT claim full extension parity) +- [x] verify help no longer says `start` skips postStart or that postAttach is `--vscode`-only +- [x] verify **start does not apply vscode customizations** still stated in help + +## 18. Failing tests — volume-mode rebuild initialize without host workspace + +- [x] 18.1 Volume-mode / clone-origin `rebuild` with no usable host workspace: `initializeCommand` of the form `bash .devcontainer/…` runs on the host; cwd is a temporary workspace root that contains the guest `.devcontainer/` directory; initialize runs before the old container is deleted and before the new container is created; after success the temp root is gone; success MUST NOT depend on other guest paths such as `./scripts/…` being on the host (path: `Tests/adevcontainerTests/RebuildCommandPhaseTests.swift`) +- [x] 18.2 [P] Guest config is root `.devcontainer.json` with no `.devcontainer/` directory (host-global `initializeCommand`): hook still runs (MUST NOT skip); temp root contains that json. `initializeCommand` non-zero: fail naming `initializeCommand`, MUST NOT create the new container, old container remains, temp root is gone after failure (path: `Tests/adevcontainerTests/RebuildCommandPhaseTests.swift`) +- [x] 18.3 [P] Clone-origin `rebuild` with a usable retained host checkout still uses that durable cwd (not a temp root created solely for the hook). Volume-mode `start` with no usable host workspace still skips initialize + warns (do not flip) (paths: `Tests/adevcontainerTests/RebuildCommandPhaseTests.swift`, `Tests/adevcontainerTests/CloneInVolumeTests.swift`) + +## Checkpoint + +- [x] verify **volume-mode rebuild without host workspace still runs initializeCommand** encoded as a failing test +- [x] verify **volume-mode rebuild initialize temp is removed after failure** encoded as a failing test +- [x] verify **missing .devcontainer directory does not skip initializeCommand on volume rebuild** encoded as a failing test +- [x] verify **volume-mode rebuild initialize is not a full workspace checkout** encoded as a failing test +- [x] verify **volume-mode rebuild with a retained host checkout uses that path** encoded as a failing test +- [x] verify **volume-mode start without host workspace skips initializeCommand** remains encoded + +## 19. Implement volume-mode rebuild initialize staging + +- [x] 19.1 On volume-mode / clone-origin `rebuild`, when `initializeCommand` is present and no usable host workspace exists: after guest files are already readable for config read, place the current guest `.devcontainer/` directory (and root `.devcontainer.json` if that is the config) onto a host temporary workspace root; run host initialize with that cwd; remove the temp after the hook (success or fail; removal failure warns only). Missing `.devcontainer` MUST NOT skip. Do not materialize the rest of the guest workspace. Do not re-fetch git. Do not rely on Apple `container cp` of named volumes. Failure MUST NOT create the new container and MUST leave the old container in place (path: `Sources/ADevContainerLib/Commands/RebuildCommand.swift`) +- [x] 19.2 [P] Keep volume-mode `start` skip+warn (do not start solely to obtain guest files). Keep durable host workspace cwd when a usable stamped / retained path exists (paths: `Sources/ADevContainerLib/Commands/StartCommand.swift`, `Sources/ADevContainerLib/Commands/RebuildCommand.swift`) + +## Checkpoint + +- [x] verify **volume-mode rebuild without host workspace still runs initializeCommand** +- [x] verify **volume-mode rebuild initialize temp is removed after failure** +- [x] verify **missing .devcontainer directory does not skip initializeCommand on volume rebuild** +- [x] verify **volume-mode rebuild initialize is not a full workspace checkout** +- [x] verify **volume-mode rebuild with a retained host checkout uses that path** +- [x] verify **volume-mode start without host workspace skips initializeCommand** (regression) +- [x] verify **rebuild hook matrix row applies** (host initialize still before the new container’s create-path hooks) +- [x] verify **initializeCommand failure blocks create** still holds on this rebuild path (no new container; old remains) + +## 20. Failing tests — remelt/bake holes + +- [x] 20.1 Derived-image bake: Features Dockerfile `LABEL devcontainer.metadata` includes **base-image** postStart/postAttach unioned with feature hooks (not features-only). FeaturesRunner build path same (path: `Tests/adevcontainerTests/AllUnitTests.swift`) +- [x] 20.2 [P] `PostAttachConfigLoader.load` with unreadable config still remelts image metadata postStart/postAttach into a feature-only stub (no initialize, no vscode apply). `mergeFeaturePostAttach` unions apply-time hooks with remelted ones (path: `Tests/adevcontainerTests/ConfigReaderTests.swift`) +- [x] 20.3 [P] Real `start` with missing stamped config still execs metadata postStart then CLI-attach postAttach (`failKeepContainer`); MUST NOT exec onCreate / updateContent / postCreate; MUST NOT delete (path: `Tests/adevcontainerTests/VSCodeOpenTests.swift`) +- [x] 20.4 [P] Fresh `up` with **empty features** runs image-metadata onCreate/updateContent/postCreate/postStart/postAttach. `up` finish remelt keeps apply-unioned base-image postAttach when the remelt source is features-only (path: `Tests/adevcontainerTests/AllCommandTests.swift`) + +## Checkpoint + +- [x] verify **derived-image LABEL includes base-image postStart/postAttach after Features build** encoded as a failing test +- [x] verify **start with unreadable config still runs metadata postStart** encoded as a failing test +- [x] verify **no-features up runs image-metadata postCreate/postStart** encoded as a failing test +- [x] verify **up finish still has base-image postAttach after remelt** encoded as a failing test + +## 21. Implement remelt/bake union + +- [x] 21.1 Bake the **unioned** contributions (base-image metadata + features) onto derived `LABEL devcontainer.metadata`; `FeatureDockerfileGenerator.write` accepts the already-unioned `contributions` from FeaturesRunner (path: `Sources/ADevContainerLib/Features/FeatureDockerfileGenerator.swift`, `Sources/ADevContainerLib/Features/FeaturesRunner.swift`) +- [x] 21.2 [P] On `start`, if config load is nil, still remelt container+image metadata into a feature-only stub and run feature-only postStart (`failKeepContainer`); postAttach same when the CLI-attach gate would run. Do not remelt onCreate/updateContent/postCreate on resume. Do not apply vscode customizations. `initializeCommand` stays host/config-only (path: `Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift`) +- [x] 21.3 [P] Fresh `up` / `rebuild` / `clone` with **empty features** still apply base-image metadata create-path + resume hooks via `FeatureContributionMerge.applyFromImage` (paths: `Sources/ADevContainerLib/Commands/UpCommand.swift`, `Sources/ADevContainerLib/Commands/RebuildCommand.swift`, `Sources/ADevContainerLib/Commands/CloneCommand.swift`, `Sources/ADevContainerLib/Features/FeatureContributionMerge.swift`) +- [x] 21.4 [P] `up` / `rebuild` finish remelt unions remelted hooks with apply-time arrays (does not replace-away base-image postAttach). Rebuild finish passes container labels (path: `Sources/ADevContainerLib/Commands/PostAttachConfigLoader.swift`, `Sources/ADevContainerLib/Commands/RebuildCommand.swift`) + +## Checkpoint + +- [x] verify **derived-image LABEL includes base-image postStart/postAttach after Features build** +- [x] verify **start with unreadable config still runs metadata postStart** +- [x] verify **start with unreadable config still runs metadata postAttach on CLI attach** +- [x] verify **no-features up runs image-metadata postCreate/postStart** +- [x] verify **up finish still has base-image postAttach after remelt** +- [x] verify **volume-mode start without host workspace skips initializeCommand** (unchanged) +- [x] verify **start does not apply vscode customizations** (regression) diff --git a/wiki/architecture.md b/wiki/architecture.md index a4db836..d048906 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -55,9 +55,9 @@ Volume mode exists for better metadata I/O (git status, node_modules, many small | `doctor` | Host/runtime readiness checks | | `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) | | `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 → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → ensure workspace volume → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** + verify `.git` (**HTTPS:** host `git credential fill` one-shot → guest `credential.helper store`; no GCM-in-guest; no host full+tar happy path) → author both → guest `--local`; else warn, no partial → create-path hooks. 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; after config/host/Features succeed → container-only delete old → create same name (bind) or same `*-ws` workspace volume (volume; data preserved, never re-clone). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split) | +| `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; after config/host/Features succeed → container-only delete old → create same name (bind) or same `*-ws` workspace volume (volume; data preserved, never re-clone). 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) | | `list [--json]` | Managed containers only (`devcontainer.managed=adevcontainer`) | -| `start [--name]` | Start a managed stopped container via `--name` or interactive picker; **no create-path / postStart hooks** (bind start-stopped `postStart` only via `up`); **never applies** settings/extensions (with or without `--vscode`). Optional `--vscode` after success is **open + postAttach only**; for 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 → 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`) | @@ -92,7 +92,7 @@ Shared primitive `BringUpRecovery`. Rebuild hard post-delete recovery is separat - **Bind-mode `hash12`:** workspace path + config path. - **Volume-mode `hash12`:** normalized git URL + config relpath (not a temp host path). Stable across reclones of the same repo/config. - **Workspace volume (volume-mode):** `adev-{base}-{hash12}-ws`. -- **Features derived tag** (when Features build runs): `adev-{base}:{hash12}` (content hash of base image + features + `recipeVersion` epoch in `DerivedImageTag`; bump epoch on install-Dockerfile semantic changes — current **`"5"`**; see [cli-runtime-boundary](conventions/cli-runtime-boundary.md)); empty base → `adevcontainer:{hash12}`. No `adevcontainer/features:` prefix. Plain config `image` (no Features) is unchanged. +- **Features derived tag** (when Features build runs): `adev-{base}:{hash12}` (content hash of base image + features + `recipeVersion` epoch in `DerivedImageTag`; bump epoch on install-Dockerfile semantic changes — current **`"6"`**; see [cli-runtime-boundary](conventions/cli-runtime-boundary.md)); empty base → `adevcontainer:{hash12}`. No `adevcontainer/features:` prefix. Plain config `image` (no Features) is unchanged. - **Labels (managed set):** stamped on create for both modes — `devcontainer.managed=adevcontainer`, `devcontainer.local_folder` (bind: host path; volume: `volume://…`), `devcontainer.config_file`, app config hash, `devcontainer.workspace_mode` (`bind` on `up`, `volume` on `clone`), `devcontainer.workspace_folder`, `devcontainer.remote_user` (**always non-empty on new creates** — resolved connection user, including `root`; empty = legacy only), `devcontainer.config_volumes` when applicable. Volume-mode also `devcontainer.git_url` (userinfo stripped), `devcontainer.workspace_volume`. - **Config hash on `up`:** reuse/start-stopped only when stamped hash matches current resolve; mismatch → `config_hash_mismatch`; use `rebuild` for a forced rebuild. See [cli-runtime-boundary](conventions/cli-runtime-boundary.md#up-reuse-vs-rebuild-forced-rebuild). - Enables find/reuse without Docker-style label filter APIs (list has no label filter — client-side filter; `list` keeps only managed). See [gaps](domain/devcontainer-apple-gaps.md). @@ -117,17 +117,17 @@ 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 run via `container exec` (not baked into image). Each hook admits **string** | **argv** | **object map** `name → string|argv` (empty `{}` no-op); map entries run **sequentially sorted by name** (not true parallel — product choice vs reference CLI). Detail: [cli-runtime-boundary — Lifecycle](conventions/cli-runtime-boundary.md#lifecycle-execution-hook-matrix). Contract: [`specs/lifecycle-hooks.md`](../specs/lifecycle-hooks.md). 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) + active [`align-official-lifecycle`](../specs/changes/align-official-lifecycle/). Matrix: | Path | Hooks | |------|--------| - | Fresh create (`up` bind, `clone` volume, or `rebuild` replacement) | `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand`; 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) | no create-path hooks; settings+extensions apply on marker drift (**not** `--vscode`-gated); feature postAttach mergeable from image metadata when `--vscode` open succeeds | + | 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) | `postStartCommand` only; then settings+extensions apply if pending (**not** `--vscode`-gated); postStart failure fails `up` but does **not** delete | - | Bare `start` | no create-path / postStart; **never applies** settings/extensions (with or without `--vscode`); with `--vscode`: open → postAttach on open success (config from labels for postAttach only; feature hooks from image metadata) | + | 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 | + | 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` | **implemented** gate on `up`/`start`/`clone`/`rebuild`: **RUNS** config then feature postAttach only after successful `--vscode` open (order on apply-commands: apply → open → postAttach); **SKIP** (+ status when any present) if flag absent or 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/); 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/) | + | `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 [`align-official-lifecycle`](../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../specs/changes/vscode-customizations-up-clone-rebuild/); 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/) | - **runArgs allowlist** and **hostRequirements** enforce+apply: [cli-runtime-boundary.md](conventions/cli-runtime-boundary.md). Contract: [`specs/runargs-host.md`](../specs/runargs-host.md). - Long-lived devcontainers use keep-alive entrypoint **`/bin/sleep` infinity** so the container stays up for `exec`/attach. @@ -139,7 +139,7 @@ Shipped under `Sources/ADevContainerLib/Features/`. On `up`/`clone`/`rebuild` wh 1. Admit **OCI** and **local path** refs; **warn-skip** docker-* markers (omit from admitted list) and warn-strip metadata `privileged` / `securityOpt` (not applied). 2. One-time consent for `build.rosetta=false` when needed (CI: `ADEVCONTAINER_ALLOW_BUILD_ROSETTA_DISABLE=1`). 3. Load local packages or fetch OCI over HTTPS (embedded client). -4. Order via `dependsOn` / `installsAfter`; build derived image via `container build --platform linux/arm64` — metadata `containerEnv` as Dockerfile **`ENV` before** install `RUN` (`$PATH`/`$VAR` expand); `install.sh` runs **as root** after `chmod -R 0755` with options + `_REMOTE_USER`/`_CONTAINER_USER` on RUN prefix (base USER when local config has no remote/container user); Dockerfile then **restores base image USER**; `recipeVersion` **`"5"`**. Reuse tag when unchanged. If BuildKit was stopped before the build, restore-after-build stops it again (best-effort); already-running / undetermined status → leave alone. +4. Order via `dependsOn` / `installsAfter`; build derived image via `container build --platform linux/arm64` — metadata `containerEnv` as Dockerfile **`ENV` before** install `RUN` (`$PATH`/`$VAR` expand); `install.sh` runs **as root** after `chmod -R 0755` with options + `_REMOTE_USER`/`_CONTAINER_USER` on RUN prefix (base USER when local config has no remote/container user); Dockerfile then **restores base image USER**; derived LABEL unions base-image + feature lifecycle; `recipeVersion` **`"6"`**. Reuse tag when unchanged. If BuildKit was stopped before the build, restore-after-build stops it again (best-effort); already-running / undetermined status → leave alone. 5. Create from derived image; merge contributions (runtime env **config wins**, `${PATH}` expansion on create and later exec). Create `-u`: explicit `containerUser`, else non-root connection user, else omit when root. **Clone-only:** if no admitted feature id is `git` or `common-utils`, inject `ghcr.io/devcontainers/features/git:1` (Features path, not apt) so populate can run **in-container full `git clone`** and in-container git works. `up` does not inject. Host git is required only for config-only sparse/shallow fetch and HTTPS `git credential fill`. @@ -148,15 +148,15 @@ Full runner steps, reject list, and progress lines: [cli-runtime-boundary.md](co ## VS Code flow -**Product (implemented):** after successful lifecycle on `up`, `start`, `clone`, or `rebuild`, optional **`--vscode`** best-effort opens VS Code on the host. **`--vscode` gates open + postAttach only** — not settings/extensions apply. Without the flag, no open/postAttach; manual attach (same URI recipe) remains valid and is **not** an apply trigger. **Apple `apple-container+` attach does not auto-install** config `customizations.vscode` — the CLI applies them on `up`/`clone`/`rebuild` (below). +**Product (implemented):** after successful lifecycle on `up`, `start`, `clone`, or `rebuild`, optional **`--vscode`** best-effort opens VS Code on the host. **`--vscode` gates open only** — not settings/extensions apply, not postAttach (except already-running `start`). Without the flag, no open; CLI-attach postAttach still runs on `up`/`clone`/`rebuild`/real start. Manual attach (same URI recipe) remains valid and is **not** an apply trigger. **Apple `apple-container+` attach does not auto-install** config `customizations.vscode` — the CLI applies them on `up`/`clone`/`rebuild` (below). **Behavior (`--vscode`):** - Runs only after lifecycle success (create/start/reuse path completed). - Invokes: `code --new-window --folder-uri "vscode-remote://apple-container+${HEX}${FOLDER}"`. -- **Order on `up`/`clone`/`rebuild` with `--vscode`:** settings+extensions apply if pending (soft-fail; **not** flag-gated) → best-effort open → on open **success**, **config** then **feature** `postAttachCommand` (fail-keep). Apply is a dedicated step (`VSCodeCustomizationsApply`), not folded into postAttach. -- **`start` with `--vscode`:** no apply; then open → postAttach on open success. -- **Soft-fail open:** missing `code` on PATH or launch failure → stderr warn; open alone does not fail the command and must **not** run postAttach. On `up`/`clone`/`rebuild`, apply may already have run; marker MAY finalize even when open soft-fails or `--vscode` is absent. -- **postAttach gate (shipped):** successful open is the CLI attach approximation. **Skip** (one status line when any postAttach present) if flag absent or open soft-fails. postAttach non-zero → fail command, **keep** container. Approximation only — no wait for VS Code Server / IDE-confirmed attach; manual UI attach does not trigger postAttach or apply. +- **Order on `up`/`clone`/`rebuild` with `--vscode`:** settings+extensions apply if pending (soft-fail; **not** flag-gated) → Ready/open after waitFor → **config** then **feature** `postAttachCommand` (CLI attach; fail-keep; open soft-fail does **not** skip). Apply is a dedicated step (`VSCodeCustomizationsApply`), not folded into postAttach. +- **`start`:** never apply. Real start: postStart then CLI-attach postAttach (not flag-gated). Already-running: open → postAttach on open success only. +- **Soft-fail open:** missing `code` on PATH or launch failure → stderr warn; open alone does not fail the command. On `up`/`clone`/`rebuild`/real start, postAttach still runs. On already-running `start`, must **not** run postAttach. On `up`/`clone`/`rebuild`, apply may already have run; marker MAY finalize even when open soft-fails or `--vscode` is absent. +- **postAttach (shipped):** CLI is the supporting tool. **RUNS** on `up`/`clone`/`rebuild`/real start after waitFor. Already-running `start`: skip (one status line when any present) if flag absent or open soft-fails. postAttach non-zero → fail command, **keep** container. Approximation only — no wait for VS Code Server / IDE-confirmed attach; manual UI attach does not trigger postAttach or apply. - **Config source:** - `up` / `clone` / `rebuild`: in-memory resolved (or stamped) config for apply; on **reuse/restart**, merge feature postAttach from image `devcontainer.metadata` (Features not re-run); vscode customizations from resolved/loadable config. - bare `start`: load from labels **for postAttach only** — bind: host paths `local_folder` + `config_file`; volume: cat stamped config in-container; then merge feature postAttach from image metadata. Load failure → treat postAttach absent (start success preserved). Load MUST NOT drive settings/extensions apply. @@ -195,7 +195,7 @@ code --new-window --folder-uri "vscode-remote://apple-container+${HEX}${FOLDER}" - Extension UI command `remote-containers.attachToAppleContainer` opens the **remote authority only** (no folder) → empty/no-folder window UX gap; the `--folder-uri` recipe avoids that. - **nameConfig** (attach defaults): write `~/Library/Application Support/Code/User/globalStorage/ms-vscode-remote.remote-containers/nameConfigs/.json` with `workspaceFolder` + `remoteUser` (from non-empty connection-user resolution / stamp) **before** launching `code`. Apple attach **ignores** nameConfig `remoteUser` for the integrated terminal (uses container default user) — create `-u` compensation covers that; nameConfig still written for other attach defaults. Folder path in the URI alone does not set remote user. -Not full Dev Containers up/rebuild or IDE-owned customizations parity; volume-mode is product `clone`, not the extension’s clone-in-volume. Contract: [`specs/vscode.md`](../specs/vscode.md) + active [`vscode-customizations-up-clone-rebuild`](../specs/changes/vscode-customizations-up-clone-rebuild/); 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/). Gaps: [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md). +Not full Dev Containers up/rebuild or IDE-owned customizations parity; volume-mode is product `clone`, not the extension’s clone-in-volume. Contract: [`specs/vscode.md`](../specs/vscode.md) + active [`align-official-lifecycle`](../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../specs/changes/vscode-customizations-up-clone-rebuild/); 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/). Gaps: [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md). ## Reference config diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index e792e68..8263244 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -150,7 +150,7 @@ Bind-mode `up` stamps the full managed label set including `workspace_mode=bind` - Bind: `hash12` = workspace path + config path. - Volume: `hash12` = normalized git URL + config relpath (not temp checkout path). - **Workspace volume (volume-mode):** `adev-{base}-{hash12}-ws`. -- **Features derived image tag:** `adev-{base}:{hash12}` where `hash12` is the content hash of base image + features + **`recipeVersion`** (epoch string in `DerivedImageTag`); empty base → `adevcontainer:{hash12}`. No `adevcontainer/features:` prefix and no `/features` path segment. Config `image` without a Features build is left as written. Tag validity depends on sanitize collapse (above). **Bump `recipeVersion` whenever install-Dockerfile semantics change** so cached local derived images are not reused forever; current epoch **`"5"`** = chmod-before-install + install-as-root then restore base USER + metadata `containerEnv` as Dockerfile `ENV` before install RUN (options + user keys on RUN prefix). Was `"4"` = same install layers but `containerEnv` on RUN prefix (single-quoted `PATH` could wipe system PATH). +- **Features derived image tag:** `adev-{base}:{hash12}` where `hash12` is the content hash of base image + features + **`recipeVersion`** (epoch string in `DerivedImageTag`); empty base → `adevcontainer:{hash12}`. No `adevcontainer/features:` prefix and no `/features` path segment. Config `image` without a Features build is left as written. Tag validity depends on sanitize collapse (above). **Bump `recipeVersion` whenever install-Dockerfile semantics change** so cached local derived images are not reused forever; current epoch **`"6"`** = derived LABEL unions base-image + feature lifecycle metadata. Was `"5"` = features-only LABEL + chmod-before-install + install-as-root then restore base USER + metadata `containerEnv` as Dockerfile `ENV` before install RUN (options + user keys on RUN prefix). Was `"4"` = same install layers but `containerEnv` on RUN prefix (single-quoted `PATH` could wipe system PATH). Do not depend on Docker-style `ps --filter label=` as the primary discovery mechanism ([gaps](../domain/devcontainer-apple-gaps.md)). @@ -204,7 +204,7 @@ Apple named volumes mount **root:root** ([gaps](../domain/devcontainer-apple-gap - `list [--json]`: client-side filter to `devcontainer.managed=adevcontainer` only. - `start` / `exec` / `stop` / `delete` / `prune` / `rebuild` / `inspect`: `--name` or picker among managed; no host workspace path required. -- `start`: runtime start of a managed container; **volume-mode runs no hooks** (bind start-stopped `postStart` stays on `up` path). 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 → CLI-attach postAttach. **Already-running:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. 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) @@ -228,9 +228,9 @@ Presentation / QUIET: [terminal-output.md](terminal-output.md) (picker stays raw | Path | Behavior | |------|----------| | `up` create | Fresh bind-mode create when no managed container for identity | -| `up` reuse / start-stopped | Only when existing container's stamped config hash **equals** current resolve. Running → reuse (no Features re-run); stopped → start + bind `postStart` only | +| `up` reuse / start-stopped | Only when existing container's stamped config hash **equals** current resolve. Running → reuse (no Features re-run; host `initializeCommand` still runs); stopped → start + `postStartCommand` + feature remelt | | `up` config hash mismatch | Fail closed: `config_hash_mismatch`; **does not** delete or replace. Hint: `adevcontainer rebuild` (managed selection: `--name` or auto) | -| `rebuild` | **Forced rebuild** (landed; archive [`20260810-rebuild`](../../specs/changes/archive/20260810-rebuild/)): read current stamped config **before** any delete; hostRequirements + Features; then container-only delete old → create same name (bind) or reuse same `adev-*-ws` (volume; ensureVolume, never delete/replace workspace volume; no re-clone). Pre-delete failure leaves old container untouched. Optional `--skip-pull` / `--vscode` / `--json`. Volume-mode rebuild: OCI features only (local-path / host DefaultFeatureFetcher unsupported — fail clean pre-delete). After start: config volume ownership always (soft-fail warn); workspace chown only if connection user ≠ stamped `remote_user` (soft-fail) — see [ownership](#named-volumes-ensure--reuse--ownership). Hard post-delete recovery mode-split (TTY `Open the recovery editor now? [Y/n]` default Y; decline/EOF retain; named retry skips prompt; README + CLI help document UX): [gaps — Failed rebuild recovery](../domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Bring-up (`up`/`clone`/`start`) is a separate primitive — [below](#bring-up-recovery-bringuprecovery) | +| `rebuild` | **Forced rebuild** (landed; archive [`20260810-rebuild`](../../specs/changes/archive/20260810-rebuild/)): read current stamped config **before** any delete; hostRequirements + Features; then container-only delete old → create same name (bind) or reuse same `adev-*-ws` (volume; ensureVolume, never delete/replace workspace volume; no re-clone). Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip; see [Lifecycle](#lifecycle-execution-hook-matrix)). Pre-delete failure leaves old container untouched. Optional `--skip-pull` / `--vscode` / `--json`. Volume-mode rebuild: OCI features only (local-path / host DefaultFeatureFetcher unsupported — fail clean pre-delete). After start: config volume ownership always (soft-fail warn); workspace chown only if connection user ≠ stamped `remote_user` (soft-fail) — see [ownership](#named-volumes-ensure--reuse--ownership). Hard post-delete recovery mode-split (TTY `Open the recovery editor now? [Y/n]` default Y; decline/EOF retain; named retry skips prompt; README + CLI help document UX): [gaps — Failed rebuild recovery](../domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Bring-up (`up`/`clone`/`start`) is a separate primitive — [below](#bring-up-recovery-bringuprecovery) | ## Bring-up recovery (`BringUpRecovery`) @@ -272,7 +272,7 @@ On `up`/`clone`/`rebuild` when `features` is non-empty after resolve (and after | build.rosetta | Before fetch/build: ensure Apple BuildKit `build.rosetta=false`. Already false → silent. True/missing → one-time TTY consent (or fail); CI auto-accept via `ADEVCONTAINER_ALLOW_BUILD_ROSETTA_DISABLE=1`. Never install Rosetta; never restore `true` after consent. Config pickup uses `restartBuilderForConfig` (stop+delete only) — **not** the restore-after-build path below | | Fetch / load | **Local path:** validate package (`devcontainer-feature.json` + `install.sh`) and copy into feature cache. **OCI:** embedded HTTPS client (`OCIFeatureClient`) — **not** `container image pull`, ORAS, or Node | | Order | `dependsOn` / `installsAfter` topo-sort (id last-segment match so `./x/sample-a` satisfies `…/sample-a:1`); cycle → structured error | -| Build | Generate Dockerfile (`FeatureDockerfileGenerator`): per feature `COPY` package; emit metadata **`containerEnv` as Dockerfile `ENV`** (so `$PATH`/`$VAR` expand — not single-quoted RUN env); then **`RUN chmod -R 0755 /tmp/adev-feature-N && … ./install.sh` as root** with **options + `_REMOTE_USER` / `_CONTAINER_USER` on the RUN prefix only** (base-image USER when config remote/container user empty — not hardcoded `vscode`/`node`; RUN prefix overwrites ENV on key collision). After all features, **restore base image OCI `USER`**. Recursive +x avoids exit **126** on bare-path lifecycle hooks copied 0644 from OCI. `container build --platform` host-native (`linux/arm64` on Apple Silicon) via `AppleContainerRuntime.build`; **no** `--rosetta` unless user opted in via `runArgs`; deterministic derived tag `adev-{base}:{hash12}` (empty base → `adevcontainer:{hash12}`; no `adevcontainer/features:` prefix); hash includes **`recipeVersion`** epoch — current **`"5"`** (chmod + root-install/restore USER + `containerEnv` as `ENV` before install RUN); reuse when tag exists. See BuildKit builder lifecycle; install-time env: [gaps — Features install containerEnv](../domain/devcontainer-apple-gaps.md#features-install-containerenv) | +| Build | Generate Dockerfile (`FeatureDockerfileGenerator`): per feature `COPY` package; emit metadata **`containerEnv` as Dockerfile `ENV`** (so `$PATH`/`$VAR` expand — not single-quoted RUN env); then **`RUN chmod -R 0755 /tmp/adev-feature-N && … ./install.sh` as root** with **options + `_REMOTE_USER` / `_CONTAINER_USER` on the RUN prefix only** (base-image USER when config remote/container user empty — not hardcoded `vscode`/`node`; RUN prefix overwrites ENV on key collision). After all features, **restore base image OCI `USER`**. Recursive +x avoids exit **126** on bare-path lifecycle hooks copied 0644 from OCI. `container build --platform` host-native (`linux/arm64` on Apple Silicon) via `AppleContainerRuntime.build`; **no** `--rosetta` unless user opted in via `runArgs`; deterministic derived tag `adev-{base}:{hash12}` (empty base → `adevcontainer:{hash12}`; no `adevcontainer/features:` prefix); hash includes **`recipeVersion`** epoch — current **`"6"`** (derived LABEL unions base-image + feature lifecycle; chmod + root-install/restore USER + `containerEnv` as `ENV` before install RUN); reuse when tag exists. See BuildKit builder lifecycle; install-time env: [gaps — Features install containerEnv](../domain/devcontainer-apple-gaps.md#features-install-containerenv) | | Merge | Feature contributions into effective config before create (`init`, `capAdd`, mounts, lifecycle hooks; runtime `containerEnv` **config wins** on create/exec — separate from install-layer merge above). PATH refs in env expanded on create **and** exec — see PATH expansion | | Create | Workspace container from **derived image** with same `--platform`. Create `-u`: explicit `containerUser`, else non-root connection user, else omit when root — see [Connection user](#connection-user-remoteuser--containeruser) | | Skip | Reuse-running path: no feature fetch/build | @@ -321,7 +321,7 @@ Presentation stack (StatusPrinter + TerminalStyle, tool `| ` framing, QUIET/colo ## Lifecycle execution (hook matrix) -Hooks run via runtime **exec** into the running container (effective user + workspace folder when set). Omitted properties and empty `{}` maps are no-ops. Nested objects rejected. Exec env PATH expansion applies (see PATH expansion). **Live I/O:** lifecycle exec enables streamOutput — child stdout+stderr teed live to host stderr framed as internal tool lines (` | ` display; raw capture); status `==> Running …` is separate StatusPrinter output. Presentation: [terminal-output.md](terminal-output.md). Contract: [`specs/lifecycle-hooks.md`](../../specs/lifecycle-hooks.md). +In-container hooks run via runtime **exec** (effective user + workspace folder when set). Host `initializeCommand` is host-process, not exec. Usable host workspace (bind / clone checkout) is cwd when present. Volume/clone-origin `rebuild` with no host workspace still runs: cwd a temp root that contains the live guest `.devcontainer/` (and root `.devcontainer.json` if that is the config); temp removed after the hook; `./scripts/…` not required. Volume `start` with no host path still skip+warn. Omitted properties and empty `{}` maps are no-ops. Nested objects rejected. `waitFor` default `updateContentCommand`. `userEnvProbe` admitted (default `loginInteractiveShell`; `none` skips). `shutdownAction` admitted (`stopCompose` fail-closed; explicit `stop` always stops). Exec env PATH expansion applies (see PATH expansion). **Live I/O:** lifecycle exec enables streamOutput — child stdout+stderr teed live to host stderr framed as internal tool lines (` | ` display; raw capture); status `==> Running …` is separate StatusPrinter output. Presentation: [terminal-output.md](terminal-output.md). Contract: [`specs/lifecycle-hooks.md`](../../specs/lifecycle-hooks.md) + active [`align-official-lifecycle`](../../specs/changes/align-official-lifecycle/). ### Command forms (`LifecycleCommand`) @@ -333,24 +333,24 @@ Each hook property admits **string** | **argv `string[]`** | **object map** `nam | argv array | exec argv directly | | object map | `LifecycleCommand.parallel([NamedLifecycleCommand…])`; empty `{}` → nil; values must be string or argv (not nested objects) | -**Object-map execution (product choice):** named entries run **sequentially, sorted by name** — fail-fast on first non-zero. Not true parallel (reference `@devcontainers/cli` may run named entries concurrently). Status labels use `property (name)` (e.g. `onCreateCommand (shell-history)`). +**Object-map execution:** named entries run **in parallel** — stage succeeds only if every entry exits 0. Status labels use `property (name)` (e.g. `onCreateCommand (shell-history)`). **Why it matters:** third-party Features often emit map-form hooks (e.g. `ghcr.io/stuartleeks/dev-container-features/shell-history:0` → `onCreateCommand: { "shell-history": "…" }`). Admitting only string/argv rejected those Features at resolve. | Path | Hooks / apply | |------|----------------| -| Fresh create (`up` bind, `clone` volume, or `rebuild` replacement) | **Named-volume ownership** (`WorkspaceOwnership`) after start, before hooks — see [ownership](#named-volumes-ensure--reuse--ownership); then `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` → **settings+extensions apply** (soft-fail; not gated on `--vscode`) | -| Reuse running (`up`, config hash match) | no create-path hooks; settings+extensions apply on marker drift (**not** `--vscode`-gated); feature postAttach mergeable from image metadata when gated open succeeds | +| Fresh create (`up` bind, `clone` volume, or `rebuild` replacement) | host `initializeCommand` (host path when present; volume/clone-origin `rebuild` with no host workspace: temp guest-config root as above) before create; **Named-volume ownership** (`WorkspaceOwnership`) after start, before hooks — see [ownership](#named-volumes-ensure--reuse--ownership); then `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` → **settings+extensions apply** (soft-fail; not gated on `--vscode`); `waitFor` default `updateContentCommand` | +| Reuse running (`up`, config hash match) | 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 hooks/delete; remediate with `rebuild` | -| Bind start-stopped (`up`, hash match) | `postStartCommand` only; then settings+extensions apply if pending (**not** `--vscode`-gated) | -| Bare `start` | no create-path / postStart; **never applies** settings/extensions (with or without `--vscode`); with `--vscode`: open → postAttach on open success | -| `customizations.vscode` | **CLI apply** config-file v1 (`VSCodeCustomizationsApply`): settings+extensions by default on `up`/`clone`/`rebuild` (create-path + `up` reuse / `up` start-stopped; **not** gated on `--vscode` or open; **not** on `start`; marketplace VSIX for **guest** `targetPlatform` linux/alpine × arm64/x64 via `uname -m`+os-release; platform-specific asset URL `?targetPlatform=` (universal omits — 404 with query); unknown arch soft-fail no host VSIX → tar-pipe → unzip → **`extensions.json` registry upsert** + cache invalidate; `metadata.pinned` false bare / true `@version`; BFS **`extensionDependencies` ∪ `extensionPack`** shared cycle guard; soft-fail per ID; seed ≠ EH/activation/`runtimeDependencies`). Order with flag on apply-commands: apply → open → postAttach. Soft-fail apply ≠ postAttach fail-keep. Marker `$HOME/.adevcontainer/vscode-customizations.applied` (config payload hash only; finalize does not require `--vscode`/open; `start` never writes). Not image build; not feature/metadata merge; Apple attach does not auto-install. Detail: [architecture.md — VS Code flow](../architecture.md#vs-code-flow); [gaps — CLI extension seed](../domain/devcontainer-apple-gaps.md#cli-extension-seed-vs-full-marketplace-install) | -| `postAttachCommand` | **implemented** on `up`/`start`/`clone`/`rebuild`: after open success (order on apply-commands: apply → open → postAttach) — **RUNS** config then feature postAttach; **SKIP** + status if no flag or open soft-fails (no status line if absent). Not always-skip-forever. Contract: [`specs/vscode.md`](../../specs/vscode.md) + active [`vscode-customizations-up-clone-rebuild`](../../specs/changes/vscode-customizations-up-clone-rebuild/); 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/) | +| Bind start-stopped (`up`, hash match) | host `initializeCommand` → `postStartCommand` + feature remelt; then settings+extensions apply if pending (**not** `--vscode`-gated); CLI-attach postAttach | +| 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 | +| `customizations.vscode` | **CLI apply** config-file v1 (`VSCodeCustomizationsApply`): settings+extensions by default on `up`/`clone`/`rebuild` (create-path + `up` reuse / `up` start-stopped; **not** gated on `--vscode` or open; **not** on `start`; marketplace VSIX for **guest** `targetPlatform` linux/alpine × arm64/x64 via `uname -m`+os-release; platform-specific asset URL `?targetPlatform=` (universal omits — 404 with query); unknown arch soft-fail no host VSIX → tar-pipe → unzip → **`extensions.json` registry upsert** + cache invalidate; `metadata.pinned` false bare / true `@version`; BFS **`extensionDependencies` ∪ `extensionPack`** shared cycle guard; soft-fail per ID; seed ≠ EH/activation/`runtimeDependencies`). Order with flag on apply-commands: apply → open → postAttach (postAttach still runs if open soft-fails). Soft-fail apply ≠ postAttach fail-keep. Marker `$HOME/.adevcontainer/vscode-customizations.applied` (config payload hash only; finalize does not require `--vscode`/open; `start` never writes). Not image build; not feature/metadata merge; Apple attach does not auto-install. Detail: [architecture.md — VS Code flow](../architecture.md#vs-code-flow); [gaps — CLI extension seed](../domain/devcontainer-apple-gaps.md#cli-extension-seed-vs-full-marketplace-install) | +| `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 if no flag or open soft-fails (no status line if absent). Order on apply-commands: apply → open → postAttach. Contract: [`specs/vscode.md`](../../specs/vscode.md) + active [`align-official-lifecycle`](../../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../../specs/changes/vscode-customizations-up-clone-rebuild/); 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/) | - Capture exit codes; failed hook fails the command — do not pretend success. - **Create-path failure** (any of onCreate / updateContent / postCreate / postStart on fresh create): delete the container **before** returning failure, so reuse cannot treat a half-bootstrapped container as healthy. Customizations apply is **not** part of create-path delete-on-fail. After delete-on-fail, `up`/`clone` may enter [bring-up recovery](#bring-up-recovery-bringuprecovery) when an editable config exists. -- **Bind start-stopped `postStartCommand` failure:** fail `up` but **do not** delete. -- **postAttach failure** (when it runs): fail the command; **do not** delete/stop the container (fail-keep; contrast create-path delete-on-fail). Open soft-fail alone does not fail the command and must not run postAttach; on `up`/`clone`/`rebuild`, apply still runs (not `--vscode`-gated; may complete before open soft-fails). `start` never applies. +- **Restart-class `postStartCommand` failure** (`up` start-stopped or real `start`): fail the command but **do not** delete. +- **postAttach failure** (when it runs): fail the command; **do not** delete/stop the container (fail-keep; contrast create-path delete-on-fail). Open soft-fail alone does not fail the command. On `up`/`clone`/`rebuild`/real start, postAttach still runs; already-running `start` must not run postAttach after open soft-fail. On `up`/`clone`/`rebuild`, apply still runs (not `--vscode`-gated). `start` never applies. - **customizations apply soft-fail:** warn stderr; never fail lifecycle exit; never delete/stop solely due to settings/extensions apply. Contrasts postAttach fail-keep. - **postAttach / apply config load:** - `up`/`clone`/`rebuild`: use resolved (or stamped) config for apply; on reuse/restart merge feature postAttach from image `devcontainer.metadata` (no Features re-run); vscode extensions/settings from resolved config. diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index 28870a0..8839fb6 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -31,7 +31,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 → ext4 in `volume.img`) | | Identity hash | workspace path + config path | normalized git URL + config relpath | | `local_folder` label | real host path | `volume://…` | -| Start hooks | bind start-stopped: `postStart` only | volume-mode `start`: **no hooks** | +| Start hooks | real start: host `initializeCommand` (if host path) + `postStart` + feature remelt + CLI-attach postAttach | same; volume start without host path skips initialize + warns | | Auth for git | N/A (host tree already present) | **SSH:** `SSH_AUTH_SOCK` + `create --ssh`. **HTTPS:** host `git credential fill` one-shot → guest `credential.helper store`. No GCM-in-guest; no host `~/.git-credentials` mount; 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) | @@ -116,11 +116,11 @@ Same E2E gate as rebuild. Do not treat the bring-up gated case as live command e ### Features build (Rosetta / platform / USER) -Apple BuildKit with `build.rosetta=true` can require Rosetta even for native arm64 image builds. Product ensures `build.rosetta=false` (one-time consent) and passes `--platform linux/arm64` on Features pull/build/create. Feature install runs **as root** then Dockerfile **restores base OCI USER** (`recipeVersion` **`"5"`**); install env uses base USER when config remote/container user empty. Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). +Apple BuildKit with `build.rosetta=true` can require Rosetta even for native arm64 image builds. Product ensures `build.rosetta=false` (one-time consent) and passes `--platform linux/arm64` on Features pull/build/create. Feature install runs **as root** then Dockerfile **restores base OCI USER**; derived LABEL unions base-image + feature lifecycle (`recipeVersion` **`"6"`**); install env uses base USER when config remote/container user empty. Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). ### Features install `containerEnv` -**Shipped** (`recipeVersion` **`"5"`**): `FeatureDockerfileGenerator` emits feature metadata `containerEnv` as Dockerfile **`ENV` before** each feature’s install `RUN` (Dockerfile expands `$PATH`/`$VAR` — single-quoted RUN-prefix `PATH` wiped system PATH, e.g. dotnet). Feature **options** and user contract keys (`_REMOTE_USER` / `_CONTAINER_USER`) stay on the install **`RUN` env prefix**. +**Shipped** (`recipeVersion` **`"6"`**): `FeatureDockerfileGenerator` emits feature metadata `containerEnv` as Dockerfile **`ENV` before** each feature’s install `RUN` (Dockerfile expands `$PATH`/`$VAR` — single-quoted RUN-prefix `PATH` wiped system PATH, e.g. dotnet). Feature **options** and user contract keys (`_REMOTE_USER` / `_CONTAINER_USER`) stay on the install **`RUN` env prefix**. | Piece | Fact | |-------|------| @@ -134,11 +134,11 @@ Detail: [cli-runtime-boundary — Features runner](../conventions/cli-runtime-bo ### VS Code attach (`--vscode` + manual) -After lifecycle success, `up` / `start` / `clone` / `rebuild` accept **`--vscode`**: best-effort host `code --new-window --folder-uri …`. Missing `code` or launch fail → stderr warn; open alone does not fail the command. **`--vscode` = open + postAttach only** — not settings/extensions apply. Without the flag, no open/postAttach; same URI recipe works manually (manual attach is **not** an apply trigger). On `up`/`clone`/`rebuild` with `--vscode`: **apply → open → postAttach** (postAttach still open-success only). On `start` with `--vscode`: no apply; then open → postAttach. Full recipe + apply policy: [architecture.md — VS Code flow](../architecture.md#vs-code-flow). Contract: [`specs/vscode.md`](../../specs/vscode.md) + active [`vscode-customizations-up-clone-rebuild`](../../specs/changes/vscode-customizations-up-clone-rebuild/); 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/). +After lifecycle success, `up` / `start` / `clone` / `rebuild` accept **`--vscode`**: best-effort host `code --new-window --folder-uri …`. Missing `code` or launch fail → stderr warn; open alone does not fail the command. **`--vscode` = open only** — not settings/extensions apply, not postAttach (except already-running `start`). Without the flag, no open; CLI-attach postAttach still runs on `up`/`clone`/`rebuild`/real start. Same URI recipe works manually (manual attach is **not** an apply trigger). On `up`/`clone`/`rebuild` with `--vscode`: **apply → open**; postAttach is CLI attach after waitFor (open soft-fail does not skip). On `start`: never apply; real start CLI-attach postAttach; already-running postAttach only after successful open. Full recipe + apply policy: [architecture.md — VS Code flow](../architecture.md#vs-code-flow). Contract: [`specs/vscode.md`](../../specs/vscode.md) + active [`align-official-lifecycle`](../../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../../specs/changes/vscode-customizations-up-clone-rebuild/); 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/). | Piece | Fact | |-------|------| -| Flag | `--vscode` on `up`, `start`, `clone`, `rebuild` (post-success only; open + postAttach gate; open soft-fail; **not** an apply gate) | +| Flag | `--vscode` on `up`, `start`, `clone`, `rebuild` (post-success only; **open** gate; open soft-fail; **not** an apply or CLI-attach postAttach gate — already-running `start` is the exception) | | Prereq | VS Code + `ms-vscode-remote.remote-containers` | | Authority | `apple-container+` + hex(UTF-8 compact JSON `{"id","image"}`) — id = create `--name` | | Open | `code --new-window --folder-uri "vscode-remote://apple-container+${HEX}${FOLDER}"` | @@ -147,11 +147,11 @@ After lifecycle success, `up` / `start` / `clone` / `rebuild` accept **`--vscode | customizations.vscode | **CLI applies** config-file only (v1; not feature/metadata merge; not image build). Helper: `VSCodeCustomizationsApply` | | settings | Merge into `~/.vscode-server/data/Machine/settings.json` under effective remote user — **create-path** after hooks on fresh `up`/`clone`/`rebuild`; repair on `up` reuse / `up` start-stopped marker drift. **Not** gated on `--vscode`. **Not** on `start`. Validated: Machine settings take effect (e.g. tabSize / insertFinalNewline) | | extensions | On `up`/`clone`/`rebuild` (fresh, `up` reuse, `up` start-stopped) **before** optional open; **not** gated on `--vscode` or open success; **not** on `start`. Marketplace VSIX for **guest** `targetPlatform` (linux/alpine × arm64/x64) → tar-pipe → guest unzip under `~/.vscode-server/extensions` (not base64-in-argv). **Registry required:** upsert `extensions.json` — folder unpack alone leaves UI at 0 installed. `metadata.pinned`: **false** bare IDs; **true** only `publisher.name@version`. Cache invalidate: best-effort rm `extensions.user.cache`. BFS **`extensionDependencies` ∪ `extensionPack`** (shared cycle guard; soft-fail per ID; e.g. Swift → `lldb-dap`). Unknown guest arch soft-fails (no host VSIX). Manual UI attach is not an apply trigger. Install-before-open usually enough; Reload Window residual MAY | -| Order with `--vscode` | `up`/`clone`/`rebuild`: apply (soft-fail) → open → postAttach on open success (fail-keep). `start`: no apply; then open → postAttach. Apply is **not** delivered via `postAttachCommand` | +| Order with `--vscode` | `up`/`clone`/`rebuild`: apply (soft-fail) → open → CLI-attach postAttach (fail-keep; open soft-fail does not skip). `start`: never apply; real start CLI-attach postAttach; already-running: open → postAttach on open success only. Apply is **not** delivered via `postAttachCommand` | | Soft-fail apply | Warn stderr; never fail lifecycle exit; never delete/stop container solely due to apply. **≠** postAttach fail-keep | | Idempotency | Guest marker `$HOME/.adevcontainer/vscode-customizations.applied` = hash of normalized **config** extensions+settings only (transitive deps side effects); skip on match; re-apply on drift on `up`/`clone`/`rebuild`; full hash only after full payload success (`--vscode`/open not required; settings-only or extensions-only MUST NOT finalize; `start` never writes) | | Identity | `customizations` stay out of create identity / config hash | -| postAttach | **Implemented:** **RUNS** config then feature postAttach only after successful `--vscode` open; **SKIP** if no flag or open soft-fails (status when any present); fail-keep; not IDE-confirmed ready | +| postAttach | **CLI attach** on `up`/`clone`/`rebuild`/real start after waitFor (not `--vscode`-gated; open soft-fail does not skip). Already-running `start`: **RUNS** only after successful `--vscode` open; **SKIP** if no flag or open soft-fails (status when any present). Fail-keep; not IDE-confirmed ready | | postAttach sources | `start`: config from labels for postAttach only (bind host paths / volume in-container cat) — **not** for apply; reuse/`start`: feature hooks from image `devcontainer.metadata` | | nameConfigs | Write `…/globalStorage/ms-vscode-remote.remote-containers/nameConfigs/.json` (`workspaceFolder` + `remoteUser`) **before** `code` launch; `remoteUser` from non-empty connection-user resolution. Apple attach **ignores** nameConfig `remoteUser` for terminal user (container default) — create `-u` compensation | | UI gap | `remote-containers.attachToAppleContainer` attaches authority **without** folder → empty window; folder-uri avoids it | @@ -174,10 +174,10 @@ 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**: run via `container exec` (hook matrix: create-path full order on `up`/`clone`; bind start-stopped `postStart` only; bare `start` no create-path/postStart; reuse none for create-path; **postAttach implemented** — runs after successful `--vscode` open only, skip otherwise, fail-keep; `start` loads config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`). Forms: string | argv | named object map (map runs sequentially sorted by name, not true parallel). Exec must expand `containerEnv` PATH refs (same as create) or login-shell hooks fail (`id`/`bash` not found). Not Docker entrypoint injection parity. Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). +- **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). Not Docker entrypoint injection parity. Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). - **`runArgs`**: allowlist (`--init`, `--cap-add`/`--cap-drop`, …); privileged/tun/device/security family **warn-skip**; unknown and first-class smuggling flags fail closed. - **`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` (see [cli-runtime-boundary](../conventions/cli-runtime-boundary.md)); **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; `recipeVersion` `"5"`. 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. +- **Features**: OCI + local path fetch/load + derived image build on `up` (see [cli-runtime-boundary](../conventions/cli-runtime-boundary.md)); **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` `"6"`. 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/). ## Reference config hotspots diff --git a/wiki/index.md b/wiki/index.md index ca5b4e9..99fc396 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -4,8 +4,8 @@ macOS Swift CLI (`adevcontainer`): read `devcontainer.json`, drive Apple `contai ## Architecture -- [architecture.md](architecture.md) — pipeline, package layout, commands (`up` bind-mode only uses `-w`; config hash mismatch → `config_hash_mismatch` → `rebuild`; `rebuild` forced-rebuild via `--name`/picker, same name/ws volume; `clone` volume-mode + auto Features `git:1` when no git/common-utils, in-container full git clone populate, SSH `--ssh` / HTTPS host credential fill + guest store; **bring-up recovery** (`BringUpRecovery`): `up`/`clone` edit-retry when editable `devcontainer.json` exists; `start` → `rebuild --name` (no editor, no re-start); `clone --resume` managed root+marker; clone recovery overlay edited config after populate; `up` leftover delete incl. later retry/`name` change; `list`/`start`/`exec`/`stop`/`delete`/`prune`/`rebuild`/`inspect` via `--name`/picker not `-w`; `up` bind stamps managed labels incl. `workspace_mode=bind`; `delete` vs `prune` attachment-aware volumes — labels `config_volumes`/`workspace_volume` candidates only, real mounts after target delete, shared preserve + warn, exit 0 share-only), bind vs named-volume workspace, identity (path vs git URL), lifecycle matrix (hooks: string|argv|object map sequential sorted-by-name, not true parallel; hook stdout+stderr live to host stderr), progress stderr, runArgs allowlist, hostRequirements, Features (OCI + local path; `recipeVersion` `"5"` metadata `containerEnv` as Dockerfile ENV before install RUN; options+user on RUN prefix; runtime config-wins); **VS Code `--vscode`** on `up`/`start`/`clone`/`rebuild` (implemented: best-effort `code --folder-uri` `vscode-remote://apple-container+` hex JSON `{id,image}`, open soft-fail; `--vscode` = **open + postAttach only**, not apply; **customizations.vscode CLI apply** — settings+extensions by default on `up`/`clone`/`rebuild` incl. `up` reuse + `up` start-stopped, **not** gated on `--vscode`/open; `start` **never** applies settings/extensions (runtime start only; `--vscode` still open+postAttach); with `--vscode` on `up`/`clone`/`rebuild`: **apply → open → postAttach** (postAttach still open-success only); **extensions.json registry upsert** required for UI (not folder-only); registry `metadata.pinned` false bare / true `@version`; BFS `extensionDependencies` ∪ `extensionPack` (shared cycle guard; soft-fail per ID; e.g. Swift→lldb-dap; csdevkit pack=csharp dep=vscode-dotnet-runtime); seed ≠ gallery (no EH/activation/`runtimeDependencies`; .NET SDK image prereq not seed; **guest** `targetPlatform` VSIX shipped — linux/alpine arm64/x64; platform-specific asset URL `?targetPlatform=`; universal omits (404 otherwise); unknown arch soft-fail no host VSIX); host download+tar-pipe+unzip + cache invalidate; soft-fail apply ≠ postAttach fail-keep; marker `$HOME/.adevcontainer/vscode-customizations.applied` config-payload only (finalize ≠ `--vscode`/open; `start` never writes marker); config-file v1 only; Apple attach does not auto-install (CLI does); Reload Window residual MAY; `start` config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`; prereqs remote-containers; manual attach without flag OK (not an apply trigger); no full Dev Containers parity; realized union of `specs/.md` + active `specs/changes/vscode-customizations-up-clone-rebuild/`; open archive `specs/changes/archive/20260808-vscode-open-flag/`; apply archive `specs/changes/archive/20260808-vscode-customizations-apply/`; rebuild archive `specs/changes/archive/20260810-rebuild/`); **connection user** (local remoteUser→containerUser→image metadata last non-empty→OCI USER→root; create `-u`: explicit containerUser else non-root connection user else omit when root — Apple attach ignores nameConfig remoteUser/uses container default; stamp non-empty `devcontainer.remote_user`; nameConfig before `code`; archive `specs/changes/archive/20260811-align-remote-user-resolution/`); binary `adevcontainer`; repo `apple-devcontainers`; host macOS 26+; tests `swift run adevcontainerTests` -- Contract: union of `specs/.md` (feature domains: core, managed-lifecycle, lifecycle-hooks, runargs-host, features, clone, vscode; includes archived `20260808-clone-in-volume`, `20260808-vscode-open-flag`, `20260808-vscode-customizations-apply`, `20260810-rebuild`, `20260811-align-remote-user-resolution`, `20260812-prune-shared-volume-safety`, `20260814-bring-up-recovery`) + active `vscode-customizations-up-clone-rebuild` +- [architecture.md](architecture.md) — pipeline, package layout, commands (`up` bind-mode only uses `-w`; config hash mismatch → `config_hash_mismatch` → `rebuild`; `rebuild` forced-rebuild via `--name`/picker, same name/ws volume; `clone` volume-mode + auto Features `git:1` when no git/common-utils, in-container full git clone populate, SSH `--ssh` / HTTPS host credential fill + guest store; **bring-up recovery** (`BringUpRecovery`): `up`/`clone` edit-retry when editable `devcontainer.json` exists; `start` → `rebuild --name` (no editor, no re-start); `clone --resume` managed root+marker; clone recovery overlay edited config after populate; `up` leftover delete incl. later retry/`name` change; `list`/`start`/`exec`/`stop`/`delete`/`prune`/`rebuild`/`inspect` via `--name`/picker not `-w`; `up` bind stamps managed labels incl. `workspace_mode=bind`; `delete` vs `prune` attachment-aware volumes — labels `config_volumes`/`workspace_volume` candidates only, real mounts after target delete, shared preserve + warn, exit 0 share-only), bind vs named-volume workspace, identity (path vs git URL), lifecycle matrix (hooks: string|argv|object-map **parallel**; host `initializeCommand`; `waitFor` default `updateContentCommand`; `userEnvProbe`/`shutdownAction` admitted; hook stdout+stderr live to host stderr), progress stderr, runArgs allowlist, hostRequirements, Features (OCI + local path; `recipeVersion` `"6"` derived LABEL unions base-image + feature lifecycle; metadata `containerEnv` as Dockerfile ENV before install RUN; options+user on RUN prefix; runtime config-wins); **VS Code `--vscode`** on `up`/`start`/`clone`/`rebuild` (implemented: best-effort `code --folder-uri` `vscode-remote://apple-container+` hex JSON `{id,image}`, open soft-fail; `--vscode` = **open only**, not apply (postAttach is CLI attach); **customizations.vscode CLI apply** — settings+extensions by default on `up`/`clone`/`rebuild` incl. `up` reuse + `up` start-stopped, **not** gated on `--vscode`/open; `start` **never** applies settings/extensions; real start: postStart (bind+volume, feature remelt) + CLI-attach postAttach; already-running start: no postStart, postAttach only after successful `--vscode` open; with `--vscode` on `up`/`clone`/`rebuild`: **apply → open**; postAttach after waitFor (open soft-fail does not skip); **extensions.json registry upsert** required for UI (not folder-only); registry `metadata.pinned` false bare / true `@version`; BFS `extensionDependencies` ∪ `extensionPack` (shared cycle guard; soft-fail per ID; e.g. Swift→lldb-dap; csdevkit pack=csharp dep=vscode-dotnet-runtime); seed ≠ gallery (no EH/activation/`runtimeDependencies`; .NET SDK image prereq not seed; **guest** `targetPlatform` VSIX shipped — linux/alpine arm64/x64; platform-specific asset URL `?targetPlatform=`; universal omits (404 otherwise); unknown arch soft-fail no host VSIX); host download+tar-pipe+unzip + cache invalidate; soft-fail apply ≠ postAttach fail-keep; marker `$HOME/.adevcontainer/vscode-customizations.applied` config-payload only (finalize ≠ `--vscode`/open; `start` never writes marker); config-file v1 only; Apple attach does not auto-install (CLI does); Reload Window residual MAY; `start` config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`; prereqs remote-containers; manual attach without flag OK (not an apply trigger); no full Dev Containers parity; realized union of `specs/.md` + active `specs/changes/vscode-customizations-up-clone-rebuild/` + `align-official-lifecycle`; open archive `specs/changes/archive/20260808-vscode-open-flag/`; apply archive `specs/changes/archive/20260808-vscode-customizations-apply/`; rebuild archive `specs/changes/archive/20260810-rebuild/`); **connection user** (local remoteUser→containerUser→image metadata last non-empty→OCI USER→root; create `-u`: explicit containerUser else non-root connection user else omit when root — Apple attach ignores nameConfig remoteUser/uses container default; stamp non-empty `devcontainer.remote_user`; nameConfig before `code`; archive `specs/changes/archive/20260811-align-remote-user-resolution/`); binary `adevcontainer`; repo `apple-devcontainers`; host macOS 26+; tests `swift run adevcontainerTests` +- Contract: union of `specs/.md` (feature domains: core, managed-lifecycle, lifecycle-hooks, runargs-host, features, clone, vscode; includes archived `20260808-clone-in-volume`, `20260808-vscode-open-flag`, `20260808-vscode-customizations-apply`, `20260810-rebuild`, `20260811-align-remote-user-resolution`, `20260812-prune-shared-volume-safety`, `20260814-bring-up-recovery`) + active `vscode-customizations-up-clone-rebuild` + `align-official-lifecycle` ## Decisions (ADRs) @@ -16,11 +16,11 @@ macOS Swift CLI (`adevcontainer`): read `devcontainer.json`, drive Apple `contai ## Domain -- [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md) — Apple container vs Docker/devcontainers gaps; bind (virtiofs/APFS) vs named volume (virtio-blk/ext4) I/O; **named volumes mount root:root** — product `WorkspaceOwnership` chowns `type=volume` targets (+ non-recursive intermediate parents; denylist stops at `/`/`/home`/…; binds never; readonly skipped; hard-fail up/clone, soft-fail rebuild) so remoteUser can write home-dir volumes / mkdir siblings; volume E2E requires a container-reachable git endpoint (`git://`, not host-only `file://`); **rebuild recovery** mode-split (volume: Alpine helper + secure temp + atomic volume write; bind rebuild: host stamped editor, no helper/Alpine/volume write); shared hard post-delete matrix + TTY `Open the recovery editor now? [Y/n]` (default Y; decline/EOF retain; named rebuild skips prompt; non-TTY unchanged); pre-delete and postAttach/settings/open no recovery; bind named retry may use host **BindRecoveryResume** when container gone; **bring-up recovery** (`BringUpRecovery`, not rebuild-only): `up`/`clone` edit-retry when editable `devcontainer.json` exists; `start` delegates to `rebuild --name` (no editor, no re-start); `clone` retains product-managed checkout, resume `clone --resume ` (managed root + marker only; never delete external path); clone recovery overlay edited config after populate (not `up`/`start`); `up` bind edits host config, retries from scratch, deletes leftover containers incl. later retry/`name` change; no recovery if config missing or clone fetch fails before config; non-TTY/`--json` never prompt; TTY default Y; recovery E2E gate `ADEVCONTAINER_RECOVERY_E2E=1` (non-TTY) / `ADEVCONTAINER_RECOVERY_E2E_TTY=1` (TTY) — rebuild non-TTY live exists; bring-up gated case `recoveryE2E_bringUpCommands_gated` is still a skip stub (does not execute commands); rebuild E2E bootstraps clone-origin stamps when guest DNS/`file://` blocks live CloneCommand populate; Feature build material under `~/Library/Caches` not `/var/folders`; recovery atomic write `chmod 644` for remoteUser (host session `0700`/`0600`); session cleanup path/ownership/session-id fail-closed; `--skip-pull` suppresses only the product pull and Apple `container` may auto-fetch at create; `container cp` silent no-op on named-volume mounts (clone populate = in-container git clone, not cp/tar happy path); SSH `--ssh` / HTTPS host credential fill + guest store (no GCM-in-guest); file binds rejected (dir only); list/inspect JSON; keep-alive `/bin/sleep`; create --name = id; Compose hard-error; privileged/devices warn-skip; config hash mismatch → `config_hash_mismatch` → `rebuild`; VS Code `--vscode` on up/start/clone/rebuild (soft-fail folder-uri open; `--vscode` = open+postAttach only; **Apple attach does not auto-install customizations.vscode** — CLI applies settings+extensions by default on `up`/`clone`/`rebuild` incl. reuse/start-stopped, not on `start` (registry upsert + `pinned` bare=false/@ver=true + BFS pack∪deps + seed≠gallery/EH/activation/`runtimeDependencies` + guest VSIX P1 + tar-pipe VSIX); soft-fail; marker config-only (finalize ≠ `--vscode`/open; `start` never writes); with flag on apply-commands: apply→open→postAttach; postAttach open-success only fail-keep; Reload residual MAY; start config from labels for postAttach only; feature hooks from image metadata; `apple-container+` hex URI; nameConfigs; UI no-folder gap; manual attach without flag not an apply trigger) + clone-in-volume analogue; **connection user** / Apple image inspect `variants[].config.config.User` + `devcontainer.metadata` remoteUser (official base: OCI root + metadata vscode; Apple attach ignores nameConfig remoteUser/uses container default → create `-u` = containerUser else non-root connection else omit root; inspect fail ≠ root; no hardcoded vscode/node); nameConfig before code; Features OCI + local path; **Features install containerEnv shipped** (`recipeVersion` `"4"`) — install RUN env merges metadata `containerEnv` → options → `_REMOTE_USER`/`_CONTAINER_USER` (later wins); runtime create/exec still config-wins; multiplatform = `base:ubuntu` + `dotnet:2` + `node:1` +- [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md) — Apple container vs Docker/devcontainers gaps; bind (virtiofs/APFS) vs named volume (virtio-blk/ext4) I/O; **named volumes mount root:root** — product `WorkspaceOwnership` chowns `type=volume` targets (+ non-recursive intermediate parents; denylist stops at `/`/`/home`/…; binds never; readonly skipped; hard-fail up/clone, soft-fail rebuild) so remoteUser can write home-dir volumes / mkdir siblings; volume E2E requires a container-reachable git endpoint (`git://`, not host-only `file://`); **rebuild recovery** mode-split (volume: Alpine helper + secure temp + atomic volume write; bind rebuild: host stamped editor, no helper/Alpine/volume write); shared hard post-delete matrix + TTY `Open the recovery editor now? [Y/n]` (default Y; decline/EOF retain; named rebuild skips prompt; non-TTY unchanged); pre-delete and postAttach/settings/open no recovery; bind named retry may use host **BindRecoveryResume** when container gone; **bring-up recovery** (`BringUpRecovery`, not rebuild-only): `up`/`clone` edit-retry when editable `devcontainer.json` exists; `start` delegates to `rebuild --name` (no editor, no re-start); `clone` retains product-managed checkout, resume `clone --resume ` (managed root + marker only; never delete external path); clone recovery overlay edited config after populate (not `up`/`start`); `up` bind edits host config, retries from scratch, deletes leftover containers incl. later retry/`name` change; no recovery if config missing or clone fetch fails before config; non-TTY/`--json` never prompt; TTY default Y; recovery E2E gate `ADEVCONTAINER_RECOVERY_E2E=1` (non-TTY) / `ADEVCONTAINER_RECOVERY_E2E_TTY=1` (TTY) — rebuild non-TTY live exists; bring-up gated case `recoveryE2E_bringUpCommands_gated` is still a skip stub (does not execute commands); rebuild E2E bootstraps clone-origin stamps when guest DNS/`file://` blocks live CloneCommand populate; Feature build material under `~/Library/Caches` not `/var/folders`; recovery atomic write `chmod 644` for remoteUser (host session `0700`/`0600`); session cleanup path/ownership/session-id fail-closed; `--skip-pull` suppresses only the product pull and Apple `container` may auto-fetch at create; `container cp` silent no-op on named-volume mounts (clone populate = in-container git clone, not cp/tar happy path); SSH `--ssh` / HTTPS host credential fill + guest store (no GCM-in-guest); file binds rejected (dir only); list/inspect JSON; keep-alive `/bin/sleep`; create --name = id; Compose hard-error; privileged/devices warn-skip; config hash mismatch → `config_hash_mismatch` → `rebuild`; VS Code `--vscode` on up/start/clone/rebuild (soft-fail folder-uri open; `--vscode` = open+postAttach only; **Apple attach does not auto-install customizations.vscode** — CLI applies settings+extensions by default on `up`/`clone`/`rebuild` incl. reuse/start-stopped, not on `start` (registry upsert + `pinned` bare=false/@ver=true + BFS pack∪deps + seed≠gallery/EH/activation/`runtimeDependencies` + guest VSIX P1 + tar-pipe VSIX); soft-fail; marker config-only (finalize ≠ `--vscode`/open; `start` never writes); with flag on apply-commands: apply→open→postAttach; postAttach open-success only fail-keep; Reload residual MAY; start config from labels for postAttach only; feature hooks from image metadata; `apple-container+` hex URI; nameConfigs; UI no-folder gap; manual attach without flag not an apply trigger) + clone-in-volume analogue; **connection user** / Apple image inspect `variants[].config.config.User` + `devcontainer.metadata` remoteUser (official base: OCI root + metadata vscode; Apple attach ignores nameConfig remoteUser/uses container default → create `-u` = containerUser else non-root connection else omit root; inspect fail ≠ root; no hardcoded vscode/node); nameConfig before code; Features OCI + local path; **Features install containerEnv shipped** (`recipeVersion` `"6"`) — metadata `containerEnv` as Dockerfile ENV before install RUN; options+user on RUN prefix; runtime create/exec still config-wins; multiplatform = `base:ubuntu` + `dotnet:2` + `node:1` ## Conventions - [terminal-output.md](conventions/terminal-output.md) — StatusPrinter + TerminalStyle + SuccessPresentation; stderr phase/warn/error vs stdout outcome/JSON; Ready→outcome→hints; tool tee ` | `; phase `item:`; `error:` (code in JSON); warn label / error label / hint cyan / success green; QUIET; `NO_COLOR`/`FORCE_COLOR`; **ManagedContainerTable** list+picker (header NAME STATE MODE GIT_URL; pad-then-style; non-running STATE `styleMuted` bold+fg 245 ≠ header `styleInfo` dim; picker lead `>` / `N)`); **InteractivePicker** stderr UI (not StatusPrinter); human `list` same table (no lead; JSON mono); user exec unframed; dev-container nomenclature -- [cli-runtime-boundary.md](conventions/cli-runtime-boundary.md) — AppleContainerRuntime; **connection user** (remoteUser→containerUser→metadata→OCI USER→root; create `-u`: explicit containerUser else non-root connection user else omit when root — Apple attach ignores nameConfig remoteUser; always stamp non-empty `remote_user` (incl. root; empty=legacy); nameConfig before code; archive `20260811-align-remote-user-resolution`); Features runner (OCI + local path; derived `container build --platform`; Dockerfile `chmod -R 0755` package before `install.sh` — ref CLI; bare-path lifecycle +x / exit 126; **`recipeVersion`** epoch in `DerivedImageTag` hash — bump on install-Dockerfile semantic change so local images not reused forever; current `"5"` = chmod-before-install + install-as-root then restore base USER + metadata `containerEnv` as Dockerfile ENV before install RUN (options + `_REMOTE_USER`/`_CONTAINER_USER` on RUN prefix; base USER when config empty); runtime create/exec merge still **config wins**; build.rosetta consent; BuildKit restore-after-build; docker-* warn-skip; PATH `${PATH}`/`$PATH` expand on create **and** exec); **`${devcontainerId}`** deferred at resolve, expand after Features/identity (mounts+containerEnv; CreateRequest safety net; volume-mode hash post-expand; shell-history); MountNormalizer file→dir bind; named volume ensure; **`WorkspaceOwnership`** (config `type=volume` chown after start before hooks; workspace `adev-*-ws` separate; intermediate parents non-recursive + system-top denylist; binds never; readonly skipped; up/clone hard-fail+delete, rebuild soft-fail warn); workspace volume `adev-*-ws`; clone flow (host sparse config-only → identity prompt → ensure Features `git:1` if no git/common-utils → volume create; SSH inject `--ssh` when agent; HTTPS host `git credential fill` one-shot + guest `credential.helper store`; ownership then in-container full `git clone` + verify `.git`; no GCM-in-guest; no host full+tar happy path; `up` no inject; eligible failure retains managed checkout + `clone --resume`); **BringUpRecovery** (`up`/`clone` edit-retry; `start` → `rebuild --name`; clone recovery overlay edited config after populate); **InteractivePicker** (managed multi `--name`/picker; **ManagedContainerTable** rows; live ↑↓/jk Enter Esc; `TerminalRawInput` ISIG off 0x03; `prefersLiveRawInput` only on `.default`; non-TTY→numbered; non-interactive `selection_required`); **`up` vs `rebuild`:** config hash mismatch → `config_hash_mismatch`; forced rebuild = `rebuild` (managed selection; same name/ws volume; pre-delete fail leaves old); **selection:** only `up` uses `-w`; `start`/`exec`/`stop`/`delete`/`prune`/`rebuild`/`inspect` use `--name`/picker; managed labels both modes (`workspace_mode=bind` on `up`, `=volume` on `clone`); `list`; `prune` resource set (+ ws volumes; labels=candidates only; attachment gate after target delete; shared preserve+warn; exit 0 share-only; archive `20260812-prune-shared-volume-safety`); progress/`==>` tee; lifecycle hook live streamOutput → host stderr (`--json` pure; QUIET status-only); machine JSON; ProcessRunner pipe drain; **InteractiveProcessRunner** `tcsetpgrp`+`SIGCONT` (inherit stdio alone insufficient; nano/vi STAT=T hang); interactive exec vs pipes; lifecycle matrix + forms string|argv|object map (sequential sorted-by-name, not true parallel; bare `start`: no create-path/postStart; bind start-stopped: postStart only via `up`; **customizations.vscode apply** settings+extensions by default on `up`/`clone`/`rebuild` (create-path + `up` reuse/`up` start-stopped; not `--vscode`-gated; registry upsert + pinned bare=false/@ver=true + BFS pack∪deps + seed≠gallery + tar-pipe); `start` never applies; `--vscode` = open+postAttach only; with flag on apply-commands: apply→open→postAttach; soft-fail ≠ postAttach fail-keep; marker config-only finalize≠`--vscode`/open (`start` never writes); **postAttach implemented** — after successful `--vscode` open, skip otherwise, fail-keep; `start` loads config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`) + create-path delete-on-fail (clone also drops `*-ws`; apply not in delete-on-fail); runArgs allowlist; hostRequirements; names/labels (volume human-base = git URL repo basename); tests: `ADEVCONTAINER_FEATURES_E2E`, `ADEVCONTAINER_RECOVERY_E2E`, `ADEVCONTAINER_RECOVERY_E2E_TTY` (bring-up gated case still skip stub); Feature material path `~/Library/Caches` not `/var/folders` +- [cli-runtime-boundary.md](conventions/cli-runtime-boundary.md) — AppleContainerRuntime; **connection user** (remoteUser→containerUser→metadata→OCI USER→root; create `-u`: explicit containerUser else non-root connection user else omit when root — Apple attach ignores nameConfig remoteUser; always stamp non-empty `remote_user` (incl. root; empty=legacy); nameConfig before code; archive `20260811-align-remote-user-resolution`); Features runner (OCI + local path; derived `container build --platform`; Dockerfile `chmod -R 0755` package before `install.sh` — ref CLI; bare-path lifecycle +x / exit 126; **`recipeVersion`** epoch in `DerivedImageTag` hash — bump on install-Dockerfile semantic change so local images not reused forever; current `"6"` = derived LABEL unions base-image + feature lifecycle; chmod-before-install + install-as-root then restore base USER + metadata `containerEnv` as Dockerfile ENV before install RUN (options + `_REMOTE_USER`/`_CONTAINER_USER` on RUN prefix; base USER when config empty); runtime create/exec merge still **config wins**; build.rosetta consent; BuildKit restore-after-build; docker-* warn-skip; PATH `${PATH}`/`$PATH` expand on create **and** exec); **`${devcontainerId}`** deferred at resolve, expand after Features/identity (mounts+containerEnv; CreateRequest safety net; volume-mode hash post-expand; shell-history); MountNormalizer file→dir bind; named volume ensure; **`WorkspaceOwnership`** (config `type=volume` chown after start before hooks; workspace `adev-*-ws` separate; intermediate parents non-recursive + system-top denylist; binds never; readonly skipped; up/clone hard-fail+delete, rebuild soft-fail warn); workspace volume `adev-*-ws`; clone flow (host sparse config-only → identity prompt → ensure Features `git:1` if no git/common-utils → volume create; SSH inject `--ssh` when agent; HTTPS host `git credential fill` one-shot + guest `credential.helper store`; ownership then in-container full `git clone` + verify `.git`; no GCM-in-guest; no host full+tar happy path; `up` no inject; eligible failure retains managed checkout + `clone --resume`); **BringUpRecovery** (`up`/`clone` edit-retry; `start` → `rebuild --name`; clone recovery overlay edited config after populate); **InteractivePicker** (managed multi `--name`/picker; **ManagedContainerTable** rows; live ↑↓/jk Enter Esc; `TerminalRawInput` ISIG off 0x03; `prefersLiveRawInput` only on `.default`; non-TTY→numbered; non-interactive `selection_required`); **`up` vs `rebuild`:** config hash mismatch → `config_hash_mismatch`; forced rebuild = `rebuild` (managed selection; same name/ws volume; pre-delete fail leaves old); **selection:** only `up` uses `-w`; `start`/`exec`/`stop`/`delete`/`prune`/`rebuild`/`inspect` use `--name`/picker; managed labels both modes (`workspace_mode=bind` on `up`, `=volume` on `clone`); `list`; `prune` resource set (+ ws volumes; labels=candidates only; attachment gate after target delete; shared preserve+warn; exit 0 share-only; archive `20260812-prune-shared-volume-safety`); progress/`==>` tee; lifecycle hook live streamOutput → host stderr (`--json` pure; QUIET status-only); machine JSON; ProcessRunner pipe drain; **InteractiveProcessRunner** `tcsetpgrp`+`SIGCONT` (inherit stdio alone insufficient; nano/vi STAT=T hang); interactive exec vs pipes; lifecycle matrix + forms string|argv|object-map **parallel** (host `initializeCommand`; `waitFor` default `updateContentCommand`; `userEnvProbe`/`shutdownAction` admitted; real `start` bind+volume: postStart + feature remelt; already-running start: no postStart; **customizations.vscode apply** settings+extensions by default on `up`/`clone`/`rebuild` (create-path + `up` reuse/`up` start-stopped; not `--vscode`-gated; registry upsert + pinned bare=false/@ver=true + BFS pack∪deps + seed≠gallery + tar-pipe); `start` never applies; `--vscode` = open only; postAttach = CLI attach on `up`/`clone`/`rebuild`/real start (not flag-gated; open soft-fail does not skip); already-running start postAttach only after successful `--vscode` open; fail-keep; marker config-only finalize≠`--vscode`/open (`start` never writes); `start` loads config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`) + create-path delete-on-fail (clone also drops `*-ws`; apply not in delete-on-fail); runArgs allowlist; hostRequirements; names/labels (volume human-base = git URL repo basename); tests: `ADEVCONTAINER_FEATURES_E2E`, `ADEVCONTAINER_RECOVERY_E2E`, `ADEVCONTAINER_RECOVERY_E2E_TTY` (bring-up gated case still skip stub); Feature material path `~/Library/Caches` not `/var/folders` - [release-distribution.md](conventions/release-distribution.md) — release, distribution, maintainer process (`main` ≠ release; ship only via `git tag vX.Y.Z` / dispatch; non-prerelease auto Homebrew bump via `HOMEBREW_TAP_TOKEN` + `scripts/render-homebrew-formula.sh`), GitHub repo `wcgomes/apple-devcontainers` (ex-`apple-dev-containers`, ex-`dev-containerization`, 301s), GitHub Actions (ci.yml / release.yml), macos-26 arm64, version inject (`Version.swift`, tag source of truth), tarball + sha256, Homebrew sole SoT `wcgomes/homebrew-tap` `Formula/adevcontainer.rb` (`brew install adevcontainer`; no in-repo `packaging/homebrew` mirror), prereleases skip brew, missing token fails non-prerelease, curl/tar fallback, COPYFILE_DISABLE, notarize deferred, branch protection on main - [workspace-devcontainer.md](conventions/workspace-devcontainer.md) — repo `.devcontainer` (`swift:6.3.3-noble` + OCI Features node:1 lts, opencode, agents-workspace); `postCreateCommand` install-tools.sh (codegraph npm global + agent wiring/init) then `swift package resolve`; node needed for npm/codegraph (opencode Features lack node); Linux tooling + fixture; not macOS product build; no runArgs/SYS_PTRACE; `customizations.vscode` swift-vscode + settings mouseWheelZoom/autoGuessEncoding (VS Code defaults, CLI apply; lldb-dap via extensionDependencies); no privileged/docker-* Features; research rejects (container-machine-vscode, MCR swift image, seccomp unconfined) From 917cb95f8fbaf38566b50d8dc1d95389f6e7686e Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 15:56:38 +0000 Subject: [PATCH 2/7] test: ensure fixture E2E image or skip Inspect cached images without pulling; pull only when missing and skip on pull failure so the default suite stays green without network. Document TTY-safe `swift run adevcontainerTests < /dev/null`. --- CONTRIBUTING.md | 2 +- .../AllIntegrationTests.swift | 84 +++++++++++++++++++ wiki/conventions/cli-runtime-boundary.md | 2 +- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3a1da8..c8f9862 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ swift build -c release # binary: .build/release/adevcontainer ``` ## Tests -Plain `swift test` may report “no tests found” on Command Line Tools hosts, which lack `XCTest.framework`; the suite of record is `swift run adevcontainerTests`. +Plain `swift test` may report “no tests found” on Command Line Tools hosts, which lack `XCTest.framework`; the suite of record is `swift run adevcontainerTests`. On a TTY, clone tests can prompt for git identity; `swift run adevcontainerTests < /dev/null` runs the suite without prompts. - Integration skips cleanly if Apple `container` is unavailable - Override image: `ADEVCONTAINER_TEST_IMAGE` diff --git a/Tests/adevcontainerTests/AllIntegrationTests.swift b/Tests/adevcontainerTests/AllIntegrationTests.swift index 406fabf..a39b524 100644 --- a/Tests/adevcontainerTests/AllIntegrationTests.swift +++ b/Tests/adevcontainerTests/AllIntegrationTests.swift @@ -119,6 +119,19 @@ enum IntegrationSupport { } } + /// Local inspect first (no network). Missing image → pull; pull failure → skip. + /// `UpCommand` still runs with `skipPull: true` so `up` does not pull again. + static func ensureTestImageOrSkip(runtime: AppleContainerRuntime, image: String) throws { + if (try? runtime.inspectImage(ref: image)) != nil { + return + } + do { + try runtime.pullImage(image) + } catch { + try MiniTest.skip("test image pull failed: \(image)") + } + } + static func runFixtureE2E( fixtureFile: String, ensureKube: Bool = false, @@ -142,6 +155,12 @@ enum IntegrationSupport { try prepareWorkspace(ws) } + // Remapped fixture image (`ADEVCONTAINER_TEST_IMAGE` already applied). Cached → no pull. + if let image = (config["image"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), !image.isEmpty { + try ensureTestImageOrSkip(runtime: runtime, image: image) + } + let up = try UpCommand.run( options: UpOptions(workspacePath: ws.path, jsonOutput: true, skipPull: true), runtime: runtime @@ -177,6 +196,71 @@ enum IntegrationSupport { } nonisolated(unsafe) let integrationTests: [(String, () throws -> Void)] = [ + ("fixtureE2E_ensureImage_doesNotPullWhenCached", { + let mock = MockProcessRunner() + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let image = "mcr.microsoft.com/devcontainers/base:ubuntu" + try IntegrationSupport.ensureTestImageOrSkip(runtime: runtime, image: image) + let inspect = mock.calls.first { $0.arguments.starts(with: ["image", "inspect"]) } + try MiniTest.expect(inspect != nil, "expected local image inspect") + try MiniTest.expectEqual(inspect!.arguments.last, image) + try MiniTest.expect( + !mock.calls.contains { $0.arguments.contains("pull") }, + "cached image must not hit the network" + ) + }), + ("fixtureE2E_ensureImage_skipsWhenPullFails", { + let image = "mcr.example/missing:tag" + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["image", "inspect"]) { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("not found".utf8)) + } + if args.contains("pull") { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("network down".utf8)) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + do { + try IntegrationSupport.ensureTestImageOrSkip(runtime: runtime, image: image) + throw MiniTest.Failure(message: "expected skip when pull fails") + } catch let skip as MiniTest.Skip { + try MiniTest.expect( + skip.message.contains(image), + "skip message should name the image" + ) + } + let pull = mock.calls.last { $0.arguments.contains("pull") } + try MiniTest.expect(pull != nil, "missing image must attempt a pull before skip") + try MiniTest.expectEqual(pull!.arguments.last, image) + }), + ("fixtureE2E_ensureImage_pullsWhenMissing", { + let image = "mcr.example/uncached:tag" + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["image", "inspect"]) { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("not found".utf8)) + } + if args.contains("pull") { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + do { + try IntegrationSupport.ensureTestImageOrSkip(runtime: runtime, image: image) + } catch is MiniTest.Skip { + throw MiniTest.Failure(message: "expected return when pull succeeds") + } + let pull = mock.calls.last { $0.arguments.contains("pull") } + try MiniTest.expect(pull != nil, "missing image must pull") + try MiniTest.expectEqual(pull!.arguments.last, image) + }), ("fixtureE2E_smoke", { // smoke.json = base:ubuntu, no local users → metadata remoteUser=vscode. try IntegrationSupport.runFixtureE2E(fixtureFile: "smoke.json", extra: { ws, runtime, _ in diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 8263244..554774e 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -291,7 +291,7 @@ On `AppleContainerRuntime.build` (Features derived image): **Fixtures:** `features-node`, `features-triple`, `features-local`, `features-docker-ood`, `features-sample/*`. -**Tests:** suite of record is `swift run adevcontainerTests`. Local E2E when Apple `container` available; OCI E2E opt-in `ADEVCONTAINER_FEATURES_E2E=1`. Recovery E2E gate: `ADEVCONTAINER_RECOVERY_E2E=1` (non-TTY) / `ADEVCONTAINER_RECOVERY_E2E_TTY=1` (TTY). Rebuild non-TTY live exists when gated (bootstraps clone-origin stamps when live `CloneCommand` populate is unreachable). Automated TTY recovery E2E absent (TTY env surfaces skip guidance). Bring-up gated case `recoveryE2E_bringUpCommands_gated` still skip-cascades and then always skips — it does not execute live bring-up commands. Feature material for rebuild/recovery git inject must use a durable host path such as `~/Library/Caches` — Apple `container build` drops/breaks `/var/folders` temp contexts. +**Tests:** suite of record is `swift run adevcontainerTests`. Local E2E when Apple `container` available (runtime-unavailable skip unchanged); fixture E2E inspects the remapped test image (`ADEVCONTAINER_TEST_IMAGE`; `ensureTestImageOrSkip`), pulls only if missing, MiniTest.skips on pull fail (default suite green without network; cached images do not pull); `UpCommand` still `skipPull: true` after harness ensure. OCI E2E opt-in `ADEVCONTAINER_FEATURES_E2E=1`. Recovery E2E gate: `ADEVCONTAINER_RECOVERY_E2E=1` (non-TTY) / `ADEVCONTAINER_RECOVERY_E2E_TTY=1` (TTY). Rebuild non-TTY live exists when gated (bootstraps clone-origin stamps when live `CloneCommand` populate is unreachable). Automated TTY recovery E2E absent (TTY env surfaces skip guidance). Bring-up gated case `recoveryE2E_bringUpCommands_gated` still skip-cascades and then always skips — it does not execute live bring-up commands. Feature material for rebuild/recovery git inject must use a durable host path such as `~/Library/Caches` — Apple `container build` drops/breaks `/var/folders` temp contexts. Progress lines: `==> Resolving features`, `==> Fetching features`, `==> Building features` (or Reusing); `==> Configuring native arm64 builds (build.rosetta=false)` only when changing config. From 6c4440e5fc194b2f6552a0b12c8e29bd645ce1bf Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 13:22:11 -0300 Subject: [PATCH 3/7] docs: refresh wiki indexes --- wiki/decisions/index.md | 8 +++----- wiki/index.md | 25 +++++++++++-------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/wiki/decisions/index.md b/wiki/decisions/index.md index 3e764de..6bb5765 100644 --- a/wiki/decisions/index.md +++ b/wiki/decisions/index.md @@ -1,7 +1,5 @@ # Decisions (ADRs) -| ADR | Summary | -|-----|---------| -| [0001](0001-greenfield-swift-cli.md) | Greenfield Swift CLI; sole runtime = Apple container | -| [0002](0002-reject-docker-ood-privileged-tun.md) | Original reject policy; **superseded in part** by 0003 for optional incompatibles; Compose/unknown still fail-closed | -| [0003](0003-warn-skip-apple-incompatibles.md) | Warn-skip docker-* features, privileged/device runArgs, privileged/securityOpt metadata; continue `up` | +- [Greenfield Swift CLI](0001-greenfield-swift-cli.md) — keywords: greenfield, Swift, runtime — Records the project’s greenfield runtime decision. +- [Apple incompatibles](0002-reject-docker-ood-privileged-tun.md) — keywords: Docker, privileges, rejection — Records the original incompatibility rejection policy. +- [Warn-skip incompatibles](0003-warn-skip-apple-incompatibles.md) — keywords: Docker, warnings, compatibility — Records the optional incompatibility warning policy. diff --git a/wiki/index.md b/wiki/index.md index 99fc396..6389028 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,26 +1,23 @@ # Wiki index -macOS Swift CLI (`adevcontainer`): read `devcontainer.json`, drive Apple `container`. Greenfield (not a @devcontainers/cli fork). Host: macOS 26+ Apple Silicon. Runtime dep: install Apple `container` separately. - ## Architecture -- [architecture.md](architecture.md) — pipeline, package layout, commands (`up` bind-mode only uses `-w`; config hash mismatch → `config_hash_mismatch` → `rebuild`; `rebuild` forced-rebuild via `--name`/picker, same name/ws volume; `clone` volume-mode + auto Features `git:1` when no git/common-utils, in-container full git clone populate, SSH `--ssh` / HTTPS host credential fill + guest store; **bring-up recovery** (`BringUpRecovery`): `up`/`clone` edit-retry when editable `devcontainer.json` exists; `start` → `rebuild --name` (no editor, no re-start); `clone --resume` managed root+marker; clone recovery overlay edited config after populate; `up` leftover delete incl. later retry/`name` change; `list`/`start`/`exec`/`stop`/`delete`/`prune`/`rebuild`/`inspect` via `--name`/picker not `-w`; `up` bind stamps managed labels incl. `workspace_mode=bind`; `delete` vs `prune` attachment-aware volumes — labels `config_volumes`/`workspace_volume` candidates only, real mounts after target delete, shared preserve + warn, exit 0 share-only), bind vs named-volume workspace, identity (path vs git URL), lifecycle matrix (hooks: string|argv|object-map **parallel**; host `initializeCommand`; `waitFor` default `updateContentCommand`; `userEnvProbe`/`shutdownAction` admitted; hook stdout+stderr live to host stderr), progress stderr, runArgs allowlist, hostRequirements, Features (OCI + local path; `recipeVersion` `"6"` derived LABEL unions base-image + feature lifecycle; metadata `containerEnv` as Dockerfile ENV before install RUN; options+user on RUN prefix; runtime config-wins); **VS Code `--vscode`** on `up`/`start`/`clone`/`rebuild` (implemented: best-effort `code --folder-uri` `vscode-remote://apple-container+` hex JSON `{id,image}`, open soft-fail; `--vscode` = **open only**, not apply (postAttach is CLI attach); **customizations.vscode CLI apply** — settings+extensions by default on `up`/`clone`/`rebuild` incl. `up` reuse + `up` start-stopped, **not** gated on `--vscode`/open; `start` **never** applies settings/extensions; real start: postStart (bind+volume, feature remelt) + CLI-attach postAttach; already-running start: no postStart, postAttach only after successful `--vscode` open; with `--vscode` on `up`/`clone`/`rebuild`: **apply → open**; postAttach after waitFor (open soft-fail does not skip); **extensions.json registry upsert** required for UI (not folder-only); registry `metadata.pinned` false bare / true `@version`; BFS `extensionDependencies` ∪ `extensionPack` (shared cycle guard; soft-fail per ID; e.g. Swift→lldb-dap; csdevkit pack=csharp dep=vscode-dotnet-runtime); seed ≠ gallery (no EH/activation/`runtimeDependencies`; .NET SDK image prereq not seed; **guest** `targetPlatform` VSIX shipped — linux/alpine arm64/x64; platform-specific asset URL `?targetPlatform=`; universal omits (404 otherwise); unknown arch soft-fail no host VSIX); host download+tar-pipe+unzip + cache invalidate; soft-fail apply ≠ postAttach fail-keep; marker `$HOME/.adevcontainer/vscode-customizations.applied` config-payload only (finalize ≠ `--vscode`/open; `start` never writes marker); config-file v1 only; Apple attach does not auto-install (CLI does); Reload Window residual MAY; `start` config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`; prereqs remote-containers; manual attach without flag OK (not an apply trigger); no full Dev Containers parity; realized union of `specs/.md` + active `specs/changes/vscode-customizations-up-clone-rebuild/` + `align-official-lifecycle`; open archive `specs/changes/archive/20260808-vscode-open-flag/`; apply archive `specs/changes/archive/20260808-vscode-customizations-apply/`; rebuild archive `specs/changes/archive/20260810-rebuild/`); **connection user** (local remoteUser→containerUser→image metadata last non-empty→OCI USER→root; create `-u`: explicit containerUser else non-root connection user else omit when root — Apple attach ignores nameConfig remoteUser/uses container default; stamp non-empty `devcontainer.remote_user`; nameConfig before `code`; archive `specs/changes/archive/20260811-align-remote-user-resolution/`); binary `adevcontainer`; repo `apple-devcontainers`; host macOS 26+; tests `swift run adevcontainerTests` -- Contract: union of `specs/.md` (feature domains: core, managed-lifecycle, lifecycle-hooks, runargs-host, features, clone, vscode; includes archived `20260808-clone-in-volume`, `20260808-vscode-open-flag`, `20260808-vscode-customizations-apply`, `20260810-rebuild`, `20260811-align-remote-user-resolution`, `20260812-prune-shared-volume-safety`, `20260814-bring-up-recovery`) + active `vscode-customizations-up-clone-rebuild` + `align-official-lifecycle` +- [Architecture](architecture.md) — keywords: architecture, lifecycle, commands — System structure, lifecycle behavior, and command routing. -## Decisions (ADRs) +## Decisions -- [decisions/index.md](decisions/index.md) — ADR routing map -- [0001](decisions/0001-greenfield-swift-cli.md) — greenfield Swift / Apple container -- [0002](decisions/0002-reject-docker-ood-privileged-tun.md) — original reject policy; superseded in part by 0003 (Compose/unknown still fail-closed) -- [0003](decisions/0003-warn-skip-apple-incompatibles.md) — warn-skip docker-* features / privileged-device runArgs / privileged-securityOpt metadata +- [Decisions](decisions/index.md) — keywords: decisions, ADRs, policy — Architectural decision records and their routing map. +- [Greenfield Swift CLI](decisions/0001-greenfield-swift-cli.md) — keywords: greenfield, Swift, runtime — Records the project’s greenfield runtime decision. +- [Apple incompatibles](decisions/0002-reject-docker-ood-privileged-tun.md) — keywords: Docker, privileges, rejection — Records the original incompatibility rejection policy. +- [Warn-skip incompatibles](decisions/0003-warn-skip-apple-incompatibles.md) — keywords: Docker, warnings, compatibility — Records the optional incompatibility warning policy. ## Domain -- [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md) — Apple container vs Docker/devcontainers gaps; bind (virtiofs/APFS) vs named volume (virtio-blk/ext4) I/O; **named volumes mount root:root** — product `WorkspaceOwnership` chowns `type=volume` targets (+ non-recursive intermediate parents; denylist stops at `/`/`/home`/…; binds never; readonly skipped; hard-fail up/clone, soft-fail rebuild) so remoteUser can write home-dir volumes / mkdir siblings; volume E2E requires a container-reachable git endpoint (`git://`, not host-only `file://`); **rebuild recovery** mode-split (volume: Alpine helper + secure temp + atomic volume write; bind rebuild: host stamped editor, no helper/Alpine/volume write); shared hard post-delete matrix + TTY `Open the recovery editor now? [Y/n]` (default Y; decline/EOF retain; named rebuild skips prompt; non-TTY unchanged); pre-delete and postAttach/settings/open no recovery; bind named retry may use host **BindRecoveryResume** when container gone; **bring-up recovery** (`BringUpRecovery`, not rebuild-only): `up`/`clone` edit-retry when editable `devcontainer.json` exists; `start` delegates to `rebuild --name` (no editor, no re-start); `clone` retains product-managed checkout, resume `clone --resume ` (managed root + marker only; never delete external path); clone recovery overlay edited config after populate (not `up`/`start`); `up` bind edits host config, retries from scratch, deletes leftover containers incl. later retry/`name` change; no recovery if config missing or clone fetch fails before config; non-TTY/`--json` never prompt; TTY default Y; recovery E2E gate `ADEVCONTAINER_RECOVERY_E2E=1` (non-TTY) / `ADEVCONTAINER_RECOVERY_E2E_TTY=1` (TTY) — rebuild non-TTY live exists; bring-up gated case `recoveryE2E_bringUpCommands_gated` is still a skip stub (does not execute commands); rebuild E2E bootstraps clone-origin stamps when guest DNS/`file://` blocks live CloneCommand populate; Feature build material under `~/Library/Caches` not `/var/folders`; recovery atomic write `chmod 644` for remoteUser (host session `0700`/`0600`); session cleanup path/ownership/session-id fail-closed; `--skip-pull` suppresses only the product pull and Apple `container` may auto-fetch at create; `container cp` silent no-op on named-volume mounts (clone populate = in-container git clone, not cp/tar happy path); SSH `--ssh` / HTTPS host credential fill + guest store (no GCM-in-guest); file binds rejected (dir only); list/inspect JSON; keep-alive `/bin/sleep`; create --name = id; Compose hard-error; privileged/devices warn-skip; config hash mismatch → `config_hash_mismatch` → `rebuild`; VS Code `--vscode` on up/start/clone/rebuild (soft-fail folder-uri open; `--vscode` = open+postAttach only; **Apple attach does not auto-install customizations.vscode** — CLI applies settings+extensions by default on `up`/`clone`/`rebuild` incl. reuse/start-stopped, not on `start` (registry upsert + `pinned` bare=false/@ver=true + BFS pack∪deps + seed≠gallery/EH/activation/`runtimeDependencies` + guest VSIX P1 + tar-pipe VSIX); soft-fail; marker config-only (finalize ≠ `--vscode`/open; `start` never writes); with flag on apply-commands: apply→open→postAttach; postAttach open-success only fail-keep; Reload residual MAY; start config from labels for postAttach only; feature hooks from image metadata; `apple-container+` hex URI; nameConfigs; UI no-folder gap; manual attach without flag not an apply trigger) + clone-in-volume analogue; **connection user** / Apple image inspect `variants[].config.config.User` + `devcontainer.metadata` remoteUser (official base: OCI root + metadata vscode; Apple attach ignores nameConfig remoteUser/uses container default → create `-u` = containerUser else non-root connection else omit root; inspect fail ≠ root; no hardcoded vscode/node); nameConfig before code; Features OCI + local path; **Features install containerEnv shipped** (`recipeVersion` `"6"`) — metadata `containerEnv` as Dockerfile ENV before install RUN; options+user on RUN prefix; runtime create/exec still config-wins; multiplatform = `base:ubuntu` + `dotnet:2` + `node:1` +- [Apple devcontainer gaps](domain/devcontainer-apple-gaps.md) — keywords: Apple, Docker, gaps — Documents compatibility gaps between Apple containers and devcontainers. ## Conventions -- [terminal-output.md](conventions/terminal-output.md) — StatusPrinter + TerminalStyle + SuccessPresentation; stderr phase/warn/error vs stdout outcome/JSON; Ready→outcome→hints; tool tee ` | `; phase `item:`; `error:` (code in JSON); warn label / error label / hint cyan / success green; QUIET; `NO_COLOR`/`FORCE_COLOR`; **ManagedContainerTable** list+picker (header NAME STATE MODE GIT_URL; pad-then-style; non-running STATE `styleMuted` bold+fg 245 ≠ header `styleInfo` dim; picker lead `>` / `N)`); **InteractivePicker** stderr UI (not StatusPrinter); human `list` same table (no lead; JSON mono); user exec unframed; dev-container nomenclature -- [cli-runtime-boundary.md](conventions/cli-runtime-boundary.md) — AppleContainerRuntime; **connection user** (remoteUser→containerUser→metadata→OCI USER→root; create `-u`: explicit containerUser else non-root connection user else omit when root — Apple attach ignores nameConfig remoteUser; always stamp non-empty `remote_user` (incl. root; empty=legacy); nameConfig before code; archive `20260811-align-remote-user-resolution`); Features runner (OCI + local path; derived `container build --platform`; Dockerfile `chmod -R 0755` package before `install.sh` — ref CLI; bare-path lifecycle +x / exit 126; **`recipeVersion`** epoch in `DerivedImageTag` hash — bump on install-Dockerfile semantic change so local images not reused forever; current `"6"` = derived LABEL unions base-image + feature lifecycle; chmod-before-install + install-as-root then restore base USER + metadata `containerEnv` as Dockerfile ENV before install RUN (options + `_REMOTE_USER`/`_CONTAINER_USER` on RUN prefix; base USER when config empty); runtime create/exec merge still **config wins**; build.rosetta consent; BuildKit restore-after-build; docker-* warn-skip; PATH `${PATH}`/`$PATH` expand on create **and** exec); **`${devcontainerId}`** deferred at resolve, expand after Features/identity (mounts+containerEnv; CreateRequest safety net; volume-mode hash post-expand; shell-history); MountNormalizer file→dir bind; named volume ensure; **`WorkspaceOwnership`** (config `type=volume` chown after start before hooks; workspace `adev-*-ws` separate; intermediate parents non-recursive + system-top denylist; binds never; readonly skipped; up/clone hard-fail+delete, rebuild soft-fail warn); workspace volume `adev-*-ws`; clone flow (host sparse config-only → identity prompt → ensure Features `git:1` if no git/common-utils → volume create; SSH inject `--ssh` when agent; HTTPS host `git credential fill` one-shot + guest `credential.helper store`; ownership then in-container full `git clone` + verify `.git`; no GCM-in-guest; no host full+tar happy path; `up` no inject; eligible failure retains managed checkout + `clone --resume`); **BringUpRecovery** (`up`/`clone` edit-retry; `start` → `rebuild --name`; clone recovery overlay edited config after populate); **InteractivePicker** (managed multi `--name`/picker; **ManagedContainerTable** rows; live ↑↓/jk Enter Esc; `TerminalRawInput` ISIG off 0x03; `prefersLiveRawInput` only on `.default`; non-TTY→numbered; non-interactive `selection_required`); **`up` vs `rebuild`:** config hash mismatch → `config_hash_mismatch`; forced rebuild = `rebuild` (managed selection; same name/ws volume; pre-delete fail leaves old); **selection:** only `up` uses `-w`; `start`/`exec`/`stop`/`delete`/`prune`/`rebuild`/`inspect` use `--name`/picker; managed labels both modes (`workspace_mode=bind` on `up`, `=volume` on `clone`); `list`; `prune` resource set (+ ws volumes; labels=candidates only; attachment gate after target delete; shared preserve+warn; exit 0 share-only; archive `20260812-prune-shared-volume-safety`); progress/`==>` tee; lifecycle hook live streamOutput → host stderr (`--json` pure; QUIET status-only); machine JSON; ProcessRunner pipe drain; **InteractiveProcessRunner** `tcsetpgrp`+`SIGCONT` (inherit stdio alone insufficient; nano/vi STAT=T hang); interactive exec vs pipes; lifecycle matrix + forms string|argv|object-map **parallel** (host `initializeCommand`; `waitFor` default `updateContentCommand`; `userEnvProbe`/`shutdownAction` admitted; real `start` bind+volume: postStart + feature remelt; already-running start: no postStart; **customizations.vscode apply** settings+extensions by default on `up`/`clone`/`rebuild` (create-path + `up` reuse/`up` start-stopped; not `--vscode`-gated; registry upsert + pinned bare=false/@ver=true + BFS pack∪deps + seed≠gallery + tar-pipe); `start` never applies; `--vscode` = open only; postAttach = CLI attach on `up`/`clone`/`rebuild`/real start (not flag-gated; open soft-fail does not skip); already-running start postAttach only after successful `--vscode` open; fail-keep; marker config-only finalize≠`--vscode`/open (`start` never writes); `start` loads config from labels for postAttach only; feature postAttach from image metadata on reuse/`start`) + create-path delete-on-fail (clone also drops `*-ws`; apply not in delete-on-fail); runArgs allowlist; hostRequirements; names/labels (volume human-base = git URL repo basename); tests: `ADEVCONTAINER_FEATURES_E2E`, `ADEVCONTAINER_RECOVERY_E2E`, `ADEVCONTAINER_RECOVERY_E2E_TTY` (bring-up gated case still skip stub); Feature material path `~/Library/Caches` not `/var/folders` -- [release-distribution.md](conventions/release-distribution.md) — release, distribution, maintainer process (`main` ≠ release; ship only via `git tag vX.Y.Z` / dispatch; non-prerelease auto Homebrew bump via `HOMEBREW_TAP_TOKEN` + `scripts/render-homebrew-formula.sh`), GitHub repo `wcgomes/apple-devcontainers` (ex-`apple-dev-containers`, ex-`dev-containerization`, 301s), GitHub Actions (ci.yml / release.yml), macos-26 arm64, version inject (`Version.swift`, tag source of truth), tarball + sha256, Homebrew sole SoT `wcgomes/homebrew-tap` `Formula/adevcontainer.rb` (`brew install adevcontainer`; no in-repo `packaging/homebrew` mirror), prereleases skip brew, missing token fails non-prerelease, curl/tar fallback, COPYFILE_DISABLE, notarize deferred, branch protection on main -- [workspace-devcontainer.md](conventions/workspace-devcontainer.md) — repo `.devcontainer` (`swift:6.3.3-noble` + OCI Features node:1 lts, opencode, agents-workspace); `postCreateCommand` install-tools.sh (codegraph npm global + agent wiring/init) then `swift package resolve`; node needed for npm/codegraph (opencode Features lack node); Linux tooling + fixture; not macOS product build; no runArgs/SYS_PTRACE; `customizations.vscode` swift-vscode + settings mouseWheelZoom/autoGuessEncoding (VS Code defaults, CLI apply; lldb-dap via extensionDependencies); no privileged/docker-* Features; research rejects (container-machine-vscode, MCR swift image, seccomp unconfined) +- [Terminal output](conventions/terminal-output.md) — keywords: terminal, output, formatting — Defines terminal output conventions and presentation behavior. +- [CLI runtime boundary](conventions/cli-runtime-boundary.md) — keywords: runtime, users, Features — Defines runtime boundaries, identities, mounts, and Feature handling. +- [Release distribution](conventions/release-distribution.md) — keywords: release, distribution, Homebrew — Defines release, packaging, and distribution conventions. +- [Workspace devcontainer](conventions/workspace-devcontainer.md) — keywords: workspace, devcontainer, tooling — Documents the repository’s development container conventions. From 45a12849c02f5b16f22d2cc2ae3c800eb9153f15 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 13:30:54 -0300 Subject: [PATCH 4/7] docs: normalize wiki index labels --- wiki/decisions/index.md | 6 +++--- wiki/index.md | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/wiki/decisions/index.md b/wiki/decisions/index.md index 6bb5765..f5c4309 100644 --- a/wiki/decisions/index.md +++ b/wiki/decisions/index.md @@ -1,5 +1,5 @@ # Decisions (ADRs) -- [Greenfield Swift CLI](0001-greenfield-swift-cli.md) — keywords: greenfield, Swift, runtime — Records the project’s greenfield runtime decision. -- [Apple incompatibles](0002-reject-docker-ood-privileged-tun.md) — keywords: Docker, privileges, rejection — Records the original incompatibility rejection policy. -- [Warn-skip incompatibles](0003-warn-skip-apple-incompatibles.md) — keywords: Docker, warnings, compatibility — Records the optional incompatibility warning policy. +- [Greenfield Swift CLI](0001-greenfield-swift-cli.md) — greenfield, Swift, runtime — Records the project’s greenfield runtime decision. +- [Apple incompatibles](0002-reject-docker-ood-privileged-tun.md) — Docker, privileges, rejection — Records the original incompatibility rejection policy. +- [Warn-skip incompatibles](0003-warn-skip-apple-incompatibles.md) — Docker, warnings, compatibility — Records the optional incompatibility warning policy. diff --git a/wiki/index.md b/wiki/index.md index 6389028..d6fc648 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -2,22 +2,22 @@ ## Architecture -- [Architecture](architecture.md) — keywords: architecture, lifecycle, commands — System structure, lifecycle behavior, and command routing. +- [Architecture](architecture.md) — architecture, lifecycle, commands — System structure, lifecycle behavior, and command routing. ## Decisions -- [Decisions](decisions/index.md) — keywords: decisions, ADRs, policy — Architectural decision records and their routing map. -- [Greenfield Swift CLI](decisions/0001-greenfield-swift-cli.md) — keywords: greenfield, Swift, runtime — Records the project’s greenfield runtime decision. -- [Apple incompatibles](decisions/0002-reject-docker-ood-privileged-tun.md) — keywords: Docker, privileges, rejection — Records the original incompatibility rejection policy. -- [Warn-skip incompatibles](decisions/0003-warn-skip-apple-incompatibles.md) — keywords: Docker, warnings, compatibility — Records the optional incompatibility warning policy. +- [Decisions](decisions/index.md) — decisions, ADRs, policy — Architectural decision records and their routing map. +- [Greenfield Swift CLI](decisions/0001-greenfield-swift-cli.md) — greenfield, Swift, runtime — Records the project’s greenfield runtime decision. +- [Apple incompatibles](decisions/0002-reject-docker-ood-privileged-tun.md) — Docker, privileges, rejection — Records the original incompatibility rejection policy. +- [Warn-skip incompatibles](decisions/0003-warn-skip-apple-incompatibles.md) — Docker, warnings, compatibility — Records the optional incompatibility warning policy. ## Domain -- [Apple devcontainer gaps](domain/devcontainer-apple-gaps.md) — keywords: Apple, Docker, gaps — Documents compatibility gaps between Apple containers and devcontainers. +- [Apple devcontainer gaps](domain/devcontainer-apple-gaps.md) — Apple, Docker, gaps — Documents compatibility gaps between Apple containers and devcontainers. ## Conventions -- [Terminal output](conventions/terminal-output.md) — keywords: terminal, output, formatting — Defines terminal output conventions and presentation behavior. -- [CLI runtime boundary](conventions/cli-runtime-boundary.md) — keywords: runtime, users, Features — Defines runtime boundaries, identities, mounts, and Feature handling. -- [Release distribution](conventions/release-distribution.md) — keywords: release, distribution, Homebrew — Defines release, packaging, and distribution conventions. -- [Workspace devcontainer](conventions/workspace-devcontainer.md) — keywords: workspace, devcontainer, tooling — Documents the repository’s development container conventions. +- [Terminal output](conventions/terminal-output.md) — terminal, output, formatting — Defines terminal output conventions and presentation behavior. +- [CLI runtime boundary](conventions/cli-runtime-boundary.md) — runtime, users, Features — Defines runtime boundaries, identities, mounts, and Feature handling. +- [Release distribution](conventions/release-distribution.md) — release, distribution, Homebrew — Defines release, packaging, and distribution conventions. +- [Workspace devcontainer](conventions/workspace-devcontainer.md) — workspace, devcontainer, tooling — Documents the repository’s development container conventions. From f2becf55571dfb4ea3e10adfc0f204e09b683859 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 16:42:01 +0000 Subject: [PATCH 5/7] docs: archive vscode customizations up clone rebuild spec --- .../20260814-vscode-customizations-up-clone-rebuild}/proposal.md | 0 .../20260814-vscode-customizations-up-clone-rebuild}/spec.md | 0 .../20260814-vscode-customizations-up-clone-rebuild}/tasks.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename specs/changes/{vscode-customizations-up-clone-rebuild => archive/20260814-vscode-customizations-up-clone-rebuild}/proposal.md (100%) rename specs/changes/{vscode-customizations-up-clone-rebuild => archive/20260814-vscode-customizations-up-clone-rebuild}/spec.md (100%) rename specs/changes/{vscode-customizations-up-clone-rebuild => archive/20260814-vscode-customizations-up-clone-rebuild}/tasks.md (100%) diff --git a/specs/changes/vscode-customizations-up-clone-rebuild/proposal.md b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md similarity index 100% rename from specs/changes/vscode-customizations-up-clone-rebuild/proposal.md rename to specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md diff --git a/specs/changes/vscode-customizations-up-clone-rebuild/spec.md b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md similarity index 100% rename from specs/changes/vscode-customizations-up-clone-rebuild/spec.md rename to specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md diff --git a/specs/changes/vscode-customizations-up-clone-rebuild/tasks.md b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/tasks.md similarity index 100% rename from specs/changes/vscode-customizations-up-clone-rebuild/tasks.md rename to specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/tasks.md From 3d5ee7a3318a43cef2c91ad14933ecf6037ea77c Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 16:46:57 +0000 Subject: [PATCH 6/7] docs: archive align official lifecycle spec --- .../proposal.md | 0 .../spec.md | 0 .../tasks.md | 0 .../proposal.md | 12 +- .../spec.md | 72 +++-- .../tasks.md | 0 specs/clone.md | 17 +- specs/core.md | 53 ++-- specs/features.md | 60 +++- specs/lifecycle-hooks.md | 281 ++++++++++++++++-- specs/managed-lifecycle.md | 43 ++- specs/vscode.md | 167 ++++++----- wiki/architecture.md | 6 +- wiki/conventions/cli-runtime-boundary.md | 4 +- wiki/domain/devcontainer-apple-gaps.md | 2 +- 15 files changed, 543 insertions(+), 174 deletions(-) rename specs/changes/{align-official-lifecycle => archive/20260814-align-official-lifecycle}/proposal.md (100%) rename specs/changes/{align-official-lifecycle => archive/20260814-align-official-lifecycle}/spec.md (100%) rename specs/changes/{align-official-lifecycle => archive/20260814-align-official-lifecycle}/tasks.md (100%) rename specs/changes/{archive/20260814-vscode-customizations-up-clone-rebuild => vscode-customizations-up-clone-rebuild}/proposal.md (68%) rename specs/changes/{archive/20260814-vscode-customizations-up-clone-rebuild => vscode-customizations-up-clone-rebuild}/spec.md (87%) rename specs/changes/{archive/20260814-vscode-customizations-up-clone-rebuild => vscode-customizations-up-clone-rebuild}/tasks.md (100%) diff --git a/specs/changes/align-official-lifecycle/proposal.md b/specs/changes/archive/20260814-align-official-lifecycle/proposal.md similarity index 100% rename from specs/changes/align-official-lifecycle/proposal.md rename to specs/changes/archive/20260814-align-official-lifecycle/proposal.md diff --git a/specs/changes/align-official-lifecycle/spec.md b/specs/changes/archive/20260814-align-official-lifecycle/spec.md similarity index 100% rename from specs/changes/align-official-lifecycle/spec.md rename to specs/changes/archive/20260814-align-official-lifecycle/spec.md diff --git a/specs/changes/align-official-lifecycle/tasks.md b/specs/changes/archive/20260814-align-official-lifecycle/tasks.md similarity index 100% rename from specs/changes/align-official-lifecycle/tasks.md rename to specs/changes/archive/20260814-align-official-lifecycle/tasks.md diff --git a/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md b/specs/changes/vscode-customizations-up-clone-rebuild/proposal.md similarity index 68% rename from specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md rename to specs/changes/vscode-customizations-up-clone-rebuild/proposal.md index ac9961d..4b9dd89 100644 --- a/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md +++ b/specs/changes/vscode-customizations-up-clone-rebuild/proposal.md @@ -2,7 +2,7 @@ ## Intent -`customizations.vscode` settings already apply on create-path and on `up` reuse / start-stopped without `--vscode`, but extensions still wait for that flag, and `adevcontainer start` can still repair settings or install pending extensions. Users who bring a container up, clone, or rebuild expect declared settings and extensions to be present for a later manual attach, without opting into editor open. This change makes both apply by default on `up`, `clone`, and `rebuild`, and keeps bare `start` a runtime start only. +`customizations.vscode` settings already apply on create-path and on `up` reuse / start-stopped without `--vscode`, but extensions still wait for that flag, and `adevcontainer start` can still repair settings or install pending extensions. Users who bring a container up, clone, or rebuild expect declared settings and extensions to be present for a later manual attach, without opting into editor open. This change makes both apply by default on `up`, `clone`, and `rebuild`. Bare `start` still MUST NOT apply settings or extensions; resume hooks follow the realized official lifecycle (not a runtime-only lock). ## Scope @@ -11,13 +11,13 @@ - **MODIFY** [vscode.md](../../vscode.md) **VS Code attach acceptance**, **Optional `--vscode` flag on up, start, clone, and rebuild**, **Apply vscode settings on create-path (and repair on drift)**, and **Vscode customizations apply idempotency** so `--vscode` no longer gates apply - **REMOVE** [vscode.md](../../vscode.md) **Apply vscode extensions when --vscode is set (before open)** and **ADD** **Apply vscode extensions on up, clone, and rebuild** (same guest install mechanism; new command gate) - **MODIFY** [core.md](../../core.md) editor customizations property surface, **No longer pure-ignore** apply references, parseable-apply scenario, and the **Up lifecycle** vscode customizations matrix -- **MODIFY** [managed-lifecycle.md](../../managed-lifecycle.md) **Start managed container** so `adevcontainer start` MUST NOT apply settings or extensions (with or without `--vscode`) while remaining runtime start only -- Unchanged and in force: postAttach still runs only after successful `--vscode` open; bare `start` still MUST NOT run create-path hooks or `postStartCommand`; bind start-stopped `postStart` remains an `up` path; identity hash still excludes customizations; Apple attach still does not auto-install; apply remains guest-side, soft-fail, marker-idempotent, and not image/Features bake +- **MODIFY** [managed-lifecycle.md](../../managed-lifecycle.md) **Start managed container** so `adevcontainer start` MUST NOT apply settings or extensions (with or without `--vscode`). Resume hooks stay as in the realized spec (this change does **not** lock `start` as runtime-only / no postStart). +- Unchanged and in force: realized **postAttachCommand policy (CLI-only)** (CLI attach model); realized start hooks (`postStart` on every real start); identity hash still excludes customizations; Apple attach still does not auto-install; apply remains guest-side, soft-fail, marker-idempotent, and not image/Features bake ## Non-goals -- Changing **postAttachCommand policy** (still `--vscode` + successful open; fail-keep; skip status when present) -- Adding `postStartCommand` or other create-path hooks to bare `adevcontainer start` (bind start-stopped postStart stays on `up`) +- Changing realized **postAttachCommand policy** (CLI attach model stays) +- Changing realized start hooks (`postStart` on every real start stays; this change only excludes vscode customizations apply on `start`) - Changing `--vscode` open behavior, nameConfig, folder-uri, or soft-fail open - Baking extensions or settings into the image, Features Dockerfile, or derived-image identity - Feature-contributed or image `devcontainer.metadata` customizations merge @@ -32,4 +32,4 @@ Lite SDD: this proposal + outcome delta `spec.md` only (no `design.md`, no `tasks.md` in this propose step). -On `up`, `clone`, and `rebuild`, after that command’s own lifecycle succeeds and the managed container is running, apply parseable config-file settings and extensions by default — including `up` reuse and `up` start-stopped — without requiring `--vscode`. On those commands, `--vscode` continues only to request best-effort open and, on open success, postAttach. `adevcontainer start` starts (or no-ops) the selected container and, when `--vscode` is set, may still open and run postAttach; it MUST NOT apply settings or extensions. Keep the existing running-guest apply path, marker, and soft-fail policy so identity and image build stay unchanged. +On `up`, `clone`, and `rebuild`, after that command’s own lifecycle succeeds and the managed container is running, apply parseable config-file settings and extensions by default — including `up` reuse and `up` start-stopped — without requiring `--vscode`. On those commands, `--vscode` continues only to request best-effort open; postAttach follows the realized CLI attach model. `adevcontainer start` starts (or no-ops) the selected container, runs realized resume hooks, and, when `--vscode` is set, may still open; it MUST NOT apply settings or extensions. Keep the existing running-guest apply path, marker, and soft-fail policy so identity and image build stay unchanged. diff --git a/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md b/specs/changes/vscode-customizations-up-clone-rebuild/spec.md similarity index 87% rename from specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md rename to specs/changes/vscode-customizations-up-clone-rebuild/spec.md index a47f762..e6e612d 100644 --- a/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md +++ b/specs/changes/vscode-customizations-up-clone-rebuild/spec.md @@ -1,6 +1,6 @@ # Change Spec: vscode-customizations-up-clone-rebuild -Delta against realized contract (union of `specs/.md`). RFC 2119 keywords apply. `--vscode` open and **postAttachCommand policy (CLI-only)** remain in force unchanged. **Vscode customizations apply is not image build** remains in force unchanged. +Delta against realized contract (union of `specs/.md`). RFC 2119 keywords apply. `--vscode` open and realized **postAttachCommand policy (CLI-only)** (CLI attach model) remain in force; this change MUST NOT re-gate postAttach on `--vscode` or lock `start` as runtime-only / no postStart. **Vscode customizations apply is not image build** remains in force unchanged. `start` MUST NOT apply vscode customizations. ## ADDED Requirements @@ -25,9 +25,9 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g **Order relative to open and postAttach** -- On `up` / `clone` / `rebuild` with `--vscode`: run extensions apply (soft-fail) **before** best-effort open, **then** open, **then** postAttach per existing **postAttachCommand policy (CLI-only)** (unchanged fail-keep; still only after open success). -- On `up` / `clone` / `rebuild` without `--vscode`: run extensions apply; MUST NOT open; MUST NOT run postAttach (skip status when postAttach is present, unchanged). -- Extensions apply failure MUST NOT by itself skip or fail open or postAttach; postAttach gating remains solely `--vscode` + open-success + presence as specified today. +- On `up` / `clone` / `rebuild` with `--vscode`: run extensions apply (soft-fail) **before** best-effort open, **then** open, **then** postAttach per realized **postAttachCommand policy (CLI-only)** (fail-keep; CLI attach is not gated on open success). +- On `up` / `clone` / `rebuild` without `--vscode`: run extensions apply; MUST NOT open; postAttach follows realized **postAttachCommand policy (CLI-only)**. +- Extensions apply failure MUST NOT by itself skip or fail open or postAttach; postAttach remains as specified in the realized policy. - Extensions apply MAY complete (and finalize the marker when full apply succeeds) even when `--vscode` is absent or open later soft-fails. **When extensions apply is SKIPPED** @@ -83,7 +83,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - Then the CLI attempts to install missing extension IDs into the remote extensions directory under the resolved remote connection user home - And each successfully installed ID is listed in the guest `extensions.json` registry (not folder-only) - And the CLI MUST NOT invoke a host VS Code open as part of that command -- And postAttach MUST NOT execute (skip status when postAttach is present) +- And postAttach follows realized **postAttachCommand policy (CLI-only)** - And lifecycle success is preserved when extensions apply soft-fails (absent unrelated failures) #### Scenario: extensions install on fresh clone without --vscode @@ -91,14 +91,16 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - When the user runs `clone` **without** `--vscode` - Then the CLI attempts the same guest extensions install after create-path hooks - And soft-fail does not fail `clone` or delete the container/volume solely due to extensions apply -- And the CLI MUST NOT open VS Code or run postAttach solely because extensions were applied +- And the CLI MUST NOT open VS Code solely because extensions were applied +- And postAttach follows realized **postAttachCommand policy (CLI-only)** #### Scenario: extensions install on rebuild without --vscode - Given a successful `rebuild` create-path on the new container, well-formed extension IDs, and no matching guest marker - When the user runs `rebuild` **without** `--vscode` - Then the CLI attempts guest extensions install after create-path hooks on the **new** container - And rebuild still reports success when apply soft-fails -- And the CLI MUST NOT open VS Code or run postAttach solely because extensions were applied +- And the CLI MUST NOT open VS Code solely because extensions were applied +- And postAttach follows realized **postAttachCommand policy (CLI-only)** #### Scenario: extensions still apply on up when --vscode is set - Given a valid config with well-formed extensions, successful create-path, and no matching guest marker @@ -118,7 +120,8 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - When the user runs `start --vscode` - Then the CLI MUST NOT install those extensions on that invocation - And after start success the CLI still attempts best-effort open -- And postAttach still runs only on open success per existing policy +- And postAttach follows realized **postAttachCommand policy (CLI-only)** +- And resume hooks still follow realized **Start managed container** #### Scenario: up reuse applies pending extensions without --vscode - Given a running managed container whose guest marker hash does not match the normalized customizations from loadable config (e.g. an extension ID added in config without rebuilding) @@ -178,7 +181,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - When the user runs with `--vscode` and open soft-fails - Then the CLI still attempts extensions install for that invocation (command gate, not open-success gate) - And lifecycle success is unchanged by open soft-fail alone -- And postAttach remains skipped per existing policy +- And postAttach follows realized **postAttachCommand policy (CLI-only)** (open soft-fail MUST NOT skip CLI-attach postAttach) - And the marker MAY be finalized when extensions apply fully succeeds even though open soft-failed #### Scenario: extensions soft-fail keeps lifecycle success @@ -212,7 +215,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g ### Requirement: VS Code attach acceptance **Domain:** `vscode` -*(Delta — replace apply bullet 4 and the docs scenario; preserve manual attach, optional open, and postAttach hook.)* +*(Delta — replace apply bullet 4 and the docs scenario. Bullet 3 stays as in the realized spec (CLI attach model). This change MUST NOT re-gate postAttach on `--vscode`.)* MVP acceptance for editor integration is: @@ -220,9 +223,10 @@ MVP acceptance for editor integration is: 2. **Optional best-effort open (additive):** When the user passes `--vscode` on `up`, `start`, `clone`, or `rebuild`, the CLI MUST attempt a best-effort open of a new VS Code window on the resolved remote workspace folder per **VS Code best-effort open**. Open failure MUST be soft (warn; lifecycle success preserved **by itself**). Without `--vscode`, no automatic open is required. -3. **CLI attach hook for postAttach:** A successful best-effort open under `--vscode` is the product’s CLI attach hook for gating `postAttachCommand` (see **postAttachCommand policy (CLI-only)**). This is an approximation of IDE attach, not confirmation that the remote session is fully ready. This bullet is unchanged. + 3. **CLI attach hook for postAttach:** Unchanged from the realized spec (CLI attach model; see **postAttachCommand policy (CLI-only)**). + + 4. **CLI apply of config-file vscode customizations:** The CLI MUST apply parseable config-file `customizations.vscode.settings` and `customizations.vscode.extensions` on `up`, `clone`, and `rebuild` (fresh create-path, `up` reuse, and `up` start-stopped) without requiring `--vscode` or a successful editor open, per the apply requirements. `adevcontainer start` MUST NOT apply settings or extensions. `--vscode` MUST NOT be an apply gate; it remains the open flag only. Manual UI attach is not an apply trigger. Apple attach still does not auto-install. Apply failures are soft-fail and MUST NOT be presented as full Dev Containers parity. -4. **CLI apply of config-file vscode customizations:** The CLI MUST apply parseable config-file `customizations.vscode.settings` and `customizations.vscode.extensions` on `up`, `clone`, and `rebuild` (fresh create-path, `up` reuse, and `up` start-stopped) without requiring `--vscode` or a successful editor open, per the apply requirements. `adevcontainer start` MUST NOT apply settings or extensions. `--vscode` MUST NOT be an apply gate; it remains the open + postAttach gate only. Manual UI attach is not an apply trigger. Apple attach still does not auto-install. Apply failures are soft-fail and MUST NOT be presented as full Dev Containers parity. #### Scenario: Running container is attachable target - Given a successful `up` (or `clone`) @@ -241,7 +245,7 @@ MVP acceptance for editor integration is: - Then the text MUST NOT claim that manual UI attach or full Dev Containers extension-driven apply is implemented - And it MUST describe soft-fail - And it MUST describe that settings and extensions apply by default on `up` / `clone` / `rebuild` -- And it MUST describe that `--vscode` gates open and postAttach only, not apply +- And it MUST describe that `--vscode` gates open, not apply - And it MUST describe that `start` does not apply settings or extensions --- @@ -249,27 +253,28 @@ MVP acceptance for editor integration is: ### Requirement: Optional `--vscode` flag on up, start, clone, and rebuild **Domain:** `vscode` -*(Delta — `--vscode` remains open + postAttach only; drop the rebuild “extensions apply (flag gate only)” clause. Remainder of this requirement is unchanged.)* +*(Delta — `--vscode` remains open only for apply purposes; drop the rebuild “extensions apply (flag gate only)” clause. postAttach follows the realized CLI attach model. `start` MUST NOT apply customizations.)* -When `--vscode` is **absent**, those commands MUST behave as today for editor open (no automatic editor open). When `--vscode` is **present**, after the command’s container lifecycle succeeds and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder** (see VS Code best-effort open). postAttach gating after that open is specified under **postAttachCommand policy (CLI-only)**. +When `--vscode` is **absent**, those commands MUST NOT invoke a host VS Code open. When `--vscode` is **present**, after the command’s container lifecycle has reached the `waitFor` connection point and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder**. postAttach after that open is specified under realized **postAttachCommand policy (CLI-only)**. -`--vscode` MUST NOT gate settings apply or extensions apply. On `up`, `clone`, and `rebuild`, customizations apply (when pending) MUST run whether the flag is present or not, and when the flag is present MUST run **before** the open attempt. On `start`, the flag still requests open + postAttach only; `start` MUST NOT apply customizations. +`--vscode` MUST NOT gate settings apply or extensions apply. On `up`, `clone`, and `rebuild`, customizations apply (when pending) MUST run whether the flag is present or not, and when the flag is present MUST run **before** the open attempt. On `start`, the flag still requests open (and postAttach per realized policy); `start` MUST NOT apply customizations. On CLI-attach paths, omitting `--vscode` MUST NOT skip postAttach. -On `rebuild`, `--vscode` behavior MUST be identical to the `up`/`clone` create path for **open and postAttach only**: after rebuild lifecycle success on the new container, customizations apply (not flag-gated) has already run or runs before open; then attempt a best-effort open; on open **success**, run the postAttach gate; on open **soft-fail**, skip postAttach with status when present — never failing rebuild solely due to open. +On `rebuild`, `--vscode` behavior MUST be identical to the `up`/`clone` create path for **open**: after rebuild lifecycle reaches `waitFor` on the new container, customizations apply (not flag-gated) has already run or runs before open; then attempt a best-effort open. postAttach follows realized **postAttachCommand policy (CLI-only)** — never failing rebuild solely due to open. -#### Scenario: --vscode still only gates open and postAttach on up +#### Scenario: --vscode still only gates open not apply on up - Given a successful `up` create-path with well-formed settings and extensions and a config that also has `postAttachCommand` - When the user runs `up` **without** `--vscode` - Then settings and extensions apply still run per the apply requirements - And the CLI MUST NOT invoke a host VS Code open -- And postAttach MUST NOT execute (skip status when present) +- And postAttach MUST execute as CLI attach #### Scenario: --vscode on start still opens without applying customizations - Given a managed container that `start` can select and a config with settings, extensions, and `postAttachCommand` - When the user runs `start --vscode` and host `code` launch succeeds - Then after start success the CLI attempts to open a new VS Code window attached to that container -- And postAttach runs after that successful open +- And postAttach follows realized **postAttachCommand policy (CLI-only)** - And the CLI MUST NOT apply settings or extensions on that `start` invocation +- And resume hooks still follow realized **Start managed container** #### Scenario: without --vscode behavior unchanged for open - Given any valid `up`, `start`, `clone`, or `rebuild` invocation @@ -363,7 +368,8 @@ When resolved config retains a non-empty well-formed `customizations.vscode.sett - Given well-formed settings, a managed container that `start` can select, and a guest marker missing or drifted - When the user runs `start --vscode` - Then the CLI MUST NOT merge or repair Machine settings on that invocation -- And open / postAttach still follow existing `--vscode` policy +- And open / postAttach still follow realized `--vscode` and **postAttachCommand policy (CLI-only)** +- And resume hooks still follow realized **Start managed container** --- @@ -454,7 +460,7 @@ The CLI MUST record successful application of the **normalized** customizations ### Requirement: Up lifecycle (create, start, reuse) **Domain:** `core` -*(Delta — replace the vscode customizations apply matrix and the following paragraph; hook and postAttach matrix rows unchanged.)* +*(Delta — replace the vscode customizations apply matrix and the following paragraph. Hook and postAttach matrix rows stay as in the realized spec (CLI attach / start hooks). This change MUST NOT restore “Bind start-stopped postStartCommand remains an `up` path only”.)* | Path | Vscode customizations apply | |------|-----------------------------| @@ -464,10 +470,10 @@ The CLI MUST record successful application of the **normalized** customizations | `up` start-stopped (matching hash) with loadable config and marker pending/drift | after `postStartCommand` when that hook runs: settings repair and extensions install as applicable (soft-fail); **not** gated on `--vscode` | | `adevcontainer start` (any flag combination) | **no** settings or extensions apply | | Any path with matching marker for full normalized payload | skip redundant settings+extensions apply (`start` still does not apply) | -| `up`/`clone`/`rebuild` with `--vscode` | apply first (if pending), then open, then postAttach only on open success per existing matrix | -| `start` with `--vscode` | no apply; then open; then postAttach only on open success per existing matrix | +| `up`/`clone`/`rebuild` with `--vscode` | apply first (if pending), then open; postAttach follows realized **postAttachCommand policy (CLI-only)** | +| `start` with `--vscode` | no apply; then open; postAttach follows realized **postAttachCommand policy (CLI-only)** | -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. Bind start-stopped `postStartCommand` remains an `up` path only. +postAttach matrix rows and gating text remain as in the realized spec. Customizations apply is **not** part of create-path delete-on-fail, **not** folded into postAttach execution, and **not** run on `start`. #### Scenario: up reuse still applies customizations - Given a matching running container and a drifted guest customizations marker @@ -485,28 +491,18 @@ postAttach matrix rows and gating text above remain in force. Customizations app ### Requirement: Start managed container **Domain:** `managed-lifecycle` -*(Delta — additive apply exclusion; selection, runtime start/no-op, no re-clone, and locked hook split are unchanged. MUST NOT add `postStartCommand` to bare `start`.)* - -**Runtime behavior** remains: start a stopped managed container; already-running is success no-op; MUST NOT re-clone; MUST NOT run the full `up` or `clone` create path. - -**Lifecycle hooks on start (locked split)** remain: volume-mode and bind-mode bare `adevcontainer start` are **runtime start only** — MUST NOT run lifecycle hooks (`postStartCommand` included). Bind start-stopped `postStartCommand` remains via `up` only. +*(Delta — apply exclusion only. Resume hooks follow realized **Start managed container** / **Lifecycle hook surface**. This change does **not** lock `start` as runtime-only and MUST NOT remove postStart from bare `start`.)* **Vscode customizations on start** - `adevcontainer start` MUST NOT apply `customizations.vscode.settings` or `customizations.vscode.extensions`, with or without `--vscode`. -- When `--vscode` is set, `start` MAY still load config from labels for **postAttach** only (load errors → treat postAttach absent; MUST NOT fail start solely for that load) and MUST still follow **VS Code best-effort open** and **postAttachCommand policy (CLI-only)**. -- Config load on `start` MUST NOT be used to apply settings or extensions. - -#### Scenario: Volume-mode start runs no hooks -- Given a volume-mode managed container with labels from clone and a config that had `postStartCommand` at create time -- When the user runs `adevcontainer start --name ` on a stopped container -- Then the container starts and **no** lifecycle hooks are executed on this path +- Config load on `start` MAY be used for hooks, open, and postAttach. It MUST NOT be used to apply settings or extensions. #### Scenario: start does not apply vscode customizations - Given a managed container whose config has well-formed settings and extensions and whose guest marker is missing or drifted - When the user runs `adevcontainer start` without or with `--vscode` - Then the CLI MUST NOT apply those settings or extensions on this path -- And MUST NOT run `postStartCommand` on this path +- And resume hooks still follow the realized Start managed container requirement ## REMOVED Requirements diff --git a/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/tasks.md b/specs/changes/vscode-customizations-up-clone-rebuild/tasks.md similarity index 100% rename from specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/tasks.md rename to specs/changes/vscode-customizations-up-clone-rebuild/tasks.md diff --git a/specs/clone.md b/specs/clone.md index d22023e..cc95c6e 100644 --- a/specs/clone.md +++ b/specs/clone.md @@ -290,13 +290,14 @@ before returning the structured failure. (Create-path hook runners that already **Lifecycle (clone fresh create)** -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 with the **same matrix as `up` fresh create**: `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` -- Hooks run via AppleContainerRuntime exec (not baked into the image). -- Non-zero exit of any create-path hook MUST fail `clone` and MUST delete the container **and** the workspace volume before returning failure (clone cleanup; container delete-on-fail aligns with `up` fresh create, plus volume-mode `*-ws` removal). -- `postAttachCommand` follows the same gated policy as `up` (run only after successful `--vscode` open; skip with status when no attach hook or open soft-failed; failure fails `clone` but MUST NOT delete container/volume solely due to postAttach failure). Create-path hook failure delete policy for onCreate/updateContent/postCreate/postStart is unchanged. +- In-container hooks run via AppleContainerRuntime exec (not baked into the image). +- Non-zero exit of any create-path hook MUST fail `clone` and MUST delete the container **and** the workspace volume before returning failure. +- `postAttachCommand` follows [vscode.md](vscode.md) **postAttachCommand policy (CLI-only)** (CLI attach at the end of successful `clone`; not `--vscode`-gated; failure fails `clone` but MUST NOT delete container/volume solely due to postAttach failure). +- `waitFor` applies as on `up` fresh create. **Temp cleanup** @@ -308,6 +309,12 @@ After successful populate, `clone` MUST run create-path lifecycle hooks with the - When clone completes create, start, and populate successfully - Then create-path hooks run in order and clone reports success +#### Scenario: clone runs postAttach without --vscode +- Given a successful clone populate and create-path hooks and `postAttachCommand` that exits 0 +- When the user runs `clone` without `--vscode` +- Then the CLI executes `postAttachCommand` +- And clone reports success + #### Scenario: Temp dirs always cleaned up - Given clone runs to success or to a mid-flow structured failure after temps were created - When the command returns @@ -318,7 +325,7 @@ After successful populate, `clone` MUST run create-path lifecycle hooks with the - When clone runs - Then clone fails structured, the managed dev container is deleted, the workspace `*-ws` volume is deleted, and temps are cleaned up -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 gating. +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. --- diff --git a/specs/core.md b/specs/core.md index fa8b494..1a69db8 100644 --- a/specs/core.md +++ b/specs/core.md @@ -115,8 +115,11 @@ The CLI MUST accept and honor the property surface below. Properties outside thi - `portsAttributes` — retained and surfaced as metadata only (no IDE auto-forward semantics promised) **Lifecycle** -- `postCreateCommand` — string, argv array, or object map (name → string|argv; map runs sequentially sorted by name); executed via runtime exec on fresh create after `updateContentCommand`; non-zero exit MUST fail `up` -- `onCreateCommand`, `updateContentCommand`, `postStartCommand`, `postAttachCommand` — same command forms; policy per lifecycle hook surface and postAttachCommand policy requirements +- `initializeCommand` — string, argv array, or object map; host command per [lifecycle-hooks.md](lifecycle-hooks.md) **initializeCommand host execution** +- `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`, `postAttachCommand` — string, argv array, or object map; object-map entries run concurrently; policy per **Lifecycle hook surface** and [vscode.md](vscode.md) **postAttachCommand policy (CLI-only)** +- `waitFor` — enum; default `updateContentCommand`; policy per **waitFor readiness** +- `userEnvProbe` — enum; default `loginInteractiveShell`; policy per **userEnvProbe merge** +- `shutdownAction` — enum; default `stopContainer` for this image/Dockerfile product; `stopCompose` fails closed; policy per **shutdownAction admission** **runArgs + hostRequirements** - `runArgs` — allowlisted subset only; mapped on create @@ -161,10 +164,15 @@ The CLI MUST accept and honor the property surface below. Properties outside thi - Then `up` fails with a structured error including the exit code and MUST NOT report overall success #### Scenario: Lifecycle / runArgs / hostRequirements property set does not hard-error as unknown -- Given a config that includes only core supported keys plus lifecycle hooks, allowlisted `runArgs`, and `hostRequirements` +- Given a config that includes only core supported keys plus the lifecycle properties in this requirement, allowlisted `runArgs`, and `hostRequirements` - When config is validated - Then validation does not fail with unsupported-property for those keys +#### Scenario: initializeCommand waitFor userEnvProbe shutdownAction admit +- Given a minimal image config that also sets valid `initializeCommand`, `waitFor`, `userEnvProbe`, and `shutdownAction` `stopContainer` +- When config is resolved +- Then resolve succeeds and those fields are available to lifecycle paths + #### Scenario: features is on the supported surface - Given a config that includes only previously supported keys plus an OCI `features` map without warn-skipped docker-* markers - When config is validated @@ -536,16 +544,16 @@ On paths that create a new container (fresh create or `rebuild`): | Path | Lifecycle | |------|-----------| -| Fresh create (missing) | onCreate → updateContent → postCreate → postStart; delete container if any of these fail | -| `rebuild ` (forced rebuild after container-only delete of the old container) | full fresh create-path onCreate → updateContent → postCreate → postStart on the **new** container; delete-on-fail applies to the **new** container; the old container was already removed (status warning on post-delete failure); a clone-origin volume failure in create/start/create-path hooks additionally offers the volume recovery session; a bind-mode failure in the same set offers the bind host-editor recovery session; non-clone volume targets retain warning-only behavior | -| Reuse running (matching identity) | no hooks | -| Start stopped | postStart only; on failure fail `up`, do not delete container | -| Any path with postAttach present and `--vscode` absent | skip execute; one status line (no attach hook) | -| Any path with postAttach present, `--vscode` set, open soft-failed/skipped | skip execute; SHOULD status that attach open did not succeed | -| Any path with postAttach present, `--vscode` set, open success | after open: run config then feature postAttach via exec; on failure fail command, keep container | +| Fresh create (missing) | Host initialize (when a host workspace exists) → onCreate → updateContent → postCreate → postStart; delete container if any create-path hook (onCreate / updateContent / postCreate / first postStart) fails; Ready / open / postAttach wait for `waitFor` (default updateContent) | +| `rebuild ` (forced rebuild after container-only delete of the old container) | Same fresh create-path on the **new** container, including host initialize (volume-mode / clone-origin with no usable host workspace: initialize still runs on a temporary workspace root that contains the guest config directory/files; temp removed after the hook); delete-on-fail applies to the **new** container; the old container was already removed (status warning on post-delete failure); recovery offer rules unchanged | +| Reuse running (matching identity) | No onCreate / updateContent / postCreate / postStart; host initialize MUST run when a host workspace exists; postAttach runs as CLI attach | +| Start stopped (`up` or bare `start`) | Host initialize when a host workspace exists; postStart (config then remelted feature postStart); on failure fail the command, do not delete; Ready / open / postAttach follow [lifecycle-hooks.md](lifecycle-hooks.md) **waitFor readiness** (this invocation’s postStart only when `waitFor` is `postStartCommand`); postAttach runs as CLI attach | +| Already-running `start` | No initialize / postStart; postAttach only after successful `--vscode` open | +| CLI-attach path (`up` / `clone` / `rebuild` / real `start`) with postAttach present | After waitFor: run config then feature postAttach; `--vscode` open soft-fail MUST NOT skip; on failure fail command, keep container | +| Already-running `start` with postAttach present and no successful `--vscode` open | skip execute; one status line | | Any path with postAttach absent | no postAttach skip line; no postAttach exec | -postAttach gating applies on `up`, `start`, `clone`, and `rebuild` after the command’s own prior lifecycle steps succeed and (when `--vscode`) after the open attempt outcome is known. postAttach is **not** part of create-path delete-on-fail. Settings/open soft-fail and postAttach failure MUST NOT enter either recovery session. +postAttach is **not** part of create-path delete-on-fail. Settings/open soft-fail and postAttach failure MUST NOT enter either recovery session. Customizations apply remains **not** part of create-path delete-on-fail, **not** folded into postAttach, and **not** run on `start`. | Path | Vscode customizations apply | |------|-----------------------------| @@ -558,7 +566,7 @@ postAttach gating applies on `up`, `start`, `clone`, and `rebuild` after the com 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. -Create-path cleanup: if any create-path hook fails before `up` returns success, the CLI MUST delete the container before failing (extend core postCreate delete-on-fail 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. +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. #### Scenario: Create then reuse - Given no existing container for the workspace @@ -568,7 +576,7 @@ Create-path cleanup: if any create-path hook fails before `up` returns success, #### Scenario: Start stopped container - Given a container previously created by `up` that is stopped - When the user runs `up` -- Then the container is started and success JSON is emitted +- Then the container is started, resume hooks run, and success JSON is emitted #### Scenario: Up JSON shape - Given a successful `up` @@ -583,7 +591,18 @@ Create-path cleanup: if any create-path hook fails before `up` returns success, #### Scenario: Create then reuse still stable with hooks - Given a successful fresh `up` with postStart configured - When the user runs `up` again while the container is running -- Then the second run reuses without re-running onCreate/updateContent/postCreate/postStart +- Then the second run reuses without re-running onCreate / updateContent / postCreate / postStart + +#### Scenario: up start-stopped remelts feature postStart +- Given a matching stopped container and a feature-contributed postStart +- When the user runs `up` +- Then feature postStart runs after the container starts +- And onCreate / updateContent / postCreate do not run + +#### Scenario: up without --vscode still runs postAttach +- Given a matching running or freshly created container and `postAttachCommand` that exits 0 +- When the user runs `up` without `--vscode` +- Then postAttach runs after waitFor is satisfied #### Scenario: Up with features builds then hooks - Given fixture-equivalent config with OCI node feature @@ -598,7 +617,7 @@ Create-path cleanup: if any create-path hook fails before `up` returns success, #### Scenario: Reuse running does not re-fetch features - Given a matching container already running with features identity satisfied - When the user runs `up` (matching hash, no rebuild) -- Then no feature fetch/build is required and lifecycle hooks are not re-run +- Then no feature fetch/build is required and onCreate / updateContent / postCreate / postStart are not re-run #### Scenario: up hash mismatch hints rebuild - Given a managed bind-mode container whose stamped `devcontainer.config_hash` does not match the resolved config hash @@ -606,9 +625,9 @@ Create-path cleanup: if any create-path hook fails before `up` returns success, - 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 -- Given a managed container being rebuilt with a config carrying all four create-path hooks +- 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 onCreate → updateContent → postCreate → postStart execute in order on the new container, and a first-hook failure deletes only 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 #### Scenario: rebuild does not require hash drift - Given a managed container whose current config hash equals the stamped hash diff --git a/specs/features.md b/specs/features.md index 670e57e..dd062ba 100644 --- a/specs/features.md +++ b/specs/features.md @@ -380,7 +380,7 @@ After features are resolved (and before create for flag contributions; lifecycle | `capAdd` | Each capability mapped via the existing **cap-add allowlist path**; disallowed names fail closed with structured error | | `containerEnv` | Merged into effective **runtime** create/exec env; **config `containerEnv` wins** on key conflict. Install-time availability of feature `containerEnv` is governed solely by **Derived image build** and MUST NOT reverse or weaken config-wins at runtime | | mounts | Bind and volume only; sources normalized with **MountNormalizer** for file→dir promotion; incompatible mount types fail structured | -| lifecycle hooks contributed by features | Appended/merged into the create-path exec order after start (installs already in derived image); same string/argv/object-map forms and failure/delete-on-fail policy as config hooks for create-path failures | +| lifecycle hooks contributed by features | Appended/merged into the create-path exec order after start (installs already in derived image); same string/argv/object-map forms and failure/delete-on-fail policy as config hooks for create-path failures. Feature `postStart` (and feature `postAttach` when postAttach runs) MUST remelt on resume per **Feature postStart remelt on resume** and [vscode.md](vscode.md) **postAttachCommand policy (CLI-only)**. Feature onCreate / updateContent / postCreate MUST NOT run on resume. | Privileged / `securityOpt` contributions are warn-stripped and not applied to create (see warn-skip requirement); other contributions still merge. @@ -406,6 +406,39 @@ Privileged / `securityOpt` contributions are warn-stripped and not applied to cr - When `up` succeeds through create - Then the contributed hook runs via runtime exec after start (features already installed in the derived image), and non-zero exit fails `up` under create-path policy +#### Scenario: Feature postStart remelts on start +- Given feature metadata contributing `postStart` and a stopped managed container from a prior successful create +- When the user runs `adevcontainer start` or `up` start-stopped +- Then the contributed postStart runs via runtime exec on this start + +#### Scenario: start with unreadable config still runs metadata postStart +- Given a stopped managed container whose stamped config cannot be read and whose image `devcontainer.metadata` contributes `postStart` +- When the user runs `adevcontainer start --name ` +- Then after the container starts, feature-only postStart runs via container exec (`failKeepContainer`) +- And onCreate / updateContent / postCreate do not run +- And vscode customizations are not applied + +#### Scenario: start with unreadable config still runs metadata postAttach on CLI attach +- Given a stopped managed container whose stamped config cannot be read and whose image `devcontainer.metadata` contributes `postAttach` +- When the user runs a real `adevcontainer start` (CLI-attach gate) +- Then feature-only postAttach runs via container exec (`failKeepContainer`) + +#### Scenario: derived-image LABEL includes base-image postStart/postAttach after Features build +- Given a base image whose `devcontainer.metadata` contributes `postStart` / `postAttach` and a feature that also contributes those hooks +- When Features builds a derived image +- Then the derived `LABEL devcontainer.metadata` includes both the base-image and feature hooks + +#### Scenario: no-features up runs image-metadata postCreate/postStart +- Given a config with empty `features` and a base image whose `devcontainer.metadata` contributes `onCreate` / `updateContent` / `postCreate` / `postStart` / `postAttach` +- When the user runs a fresh `up` +- Then those image-metadata hooks run via container exec on the create path (and postAttach as CLI attach) +- And Features `container build` does not run + +#### 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) + #### Scenario: devcontainer.metadata label merge when present - Given a base image with a parseable `devcontainer.metadata` label - When features/metadata merge runs @@ -416,6 +449,31 @@ Privileged / `securityOpt` contributions are warn-stripped and not applied to cr - When `up` runs with features - Then absence alone does not fail `up` +### 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. + +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. + +When `start` recovery delegates to `rebuild`, the rebuild create-path already includes config and feature postStart. That recovery MUST NOT run an additional postStart after rebuild returns. + +#### Scenario: volume-mode start remelts feature postStart +- Given a stopped volume-mode managed container created with a feature that contributed `postStart` (and optional config `postStartCommand`) +- When the user runs `adevcontainer start --name ` +- Then after the container starts, config postStart (when present) then the feature postStart run via container exec +- And the command succeeds if those commands exit 0 + +#### Scenario: up start-stopped remelts feature postStart +- Given a matching stopped bind-mode container and remeltable feature postStart +- When the user runs `adevcontainer up` +- Then feature postStart runs on this start (not only the original create) +- And onCreate / updateContent / postCreate do not run + +#### Scenario: start recovery via rebuild does not double-run postStart +- Given `start` fails and recovery delegates to `rebuild` for that container +- When rebuild’s create-path runs `postStartCommand` (config and features) on the new container +- Then the user-visible start-recovery path does not run `postStartCommand` a second time after rebuild returns + --- ### Requirement: Features progress status lines diff --git a/specs/lifecycle-hooks.md b/specs/lifecycle-hooks.md index 6df01c6..b591ce1 100644 --- a/specs/lifecycle-hooks.md +++ b/specs/lifecycle-hooks.md @@ -2,36 +2,50 @@ ## Purpose -Lifecycle hook surface for `onCreateCommand` through `postStartCommand` (string | argv | object-map forms, create-path order, reuse/start behavior, delete-on-fail). postAttach gating 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, 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). ## Requirements ### Requirement: Lifecycle hook surface -The CLI MUST admit and honor these lifecycle properties in addition to existing `postCreateCommand`. Each property MUST accept a **string**, an **argv array of strings**, or an **object map** of name → string or argv array (Dev Containers named/parallel form; product runs named entries sequentially in sorted name order). Omitted properties and empty object maps MUST be treated as no-ops. Hooks that run MUST execute via AppleContainerRuntime **exec** into the running container (not baked into the image), using the **resolved remote connection user** (see [core.md](core.md) **Remote connection user resolution**) and workspace folder when set — not create-only `containerUser` when `remoteUser` differs. +The CLI MUST admit and honor these lifecycle properties. Each command property MUST accept a **string**, an **argv array of strings**, or an **object map** of name → string or argv array. Omitted properties and empty object maps MUST be treated as no-ops. + +**Object-map form (official parallel):** each named entry in a stage MUST run concurrently. The stage succeeds only if every entry exits 0. Sequential sorted-by-name MUST NOT be the required behavior. + +In-container hooks that run MUST execute via AppleContainerRuntime **exec** into the running container (not baked into the image), using the **resolved remote connection user** (see [core.md](core.md) **Remote connection user resolution**) and workspace folder when set — not create-only `containerUser` when `remoteUser` differs. String vs argv invocation MUST keep the existing product rules (`sh -lc` for strings; argv without a shell). `initializeCommand` is the host exception (see **initializeCommand host execution**). | Property | Role | |----------|------| +| `initializeCommand` | Host command at the start of `up` / `clone` / `rebuild` and of a real start when a host workspace exists; volume-mode / clone-origin rebuild with no host workspace still runs on a temporary workspace root that contains the guest config directory/files | | `onCreateCommand` | Once on fresh create, before content/update and postCreate | -| `updateContentCommand` | On fresh create after `onCreateCommand` | -| `postCreateCommand` | On fresh create after `updateContentCommand` (core; kept) | -| `postStartCommand` | After the container is running on fresh create (after postCreate) and on start of a stopped container | -| `postAttachCommand` | Admitted; executed only after successful `--vscode` open (CLI attach hook); otherwise skipped with status when present (see postAttachCommand policy) | +| `updateContentCommand` | On fresh create after `onCreateCommand` (no cloud periodic rerun) | +| `postCreateCommand` | On fresh create after `updateContentCommand` | +| `postStartCommand` | After every successful start of the container: end of fresh create (after postCreate) and start of a previously stopped container (`up` start-stopped and bare `start`, bind and volume) | +| `postAttachCommand` | Admitted; executed per [vscode.md](vscode.md) **postAttachCommand policy (CLI-only)** | +| `waitFor` | Enum; default `updateContentCommand`; see **waitFor readiness** | +| `userEnvProbe` | Enum; default `loginInteractiveShell`; see **userEnvProbe merge** | +| `shutdownAction` | Enum; see **shutdownAction admission** | + +Create-path order on fresh `up` / `clone` / `rebuild` remains initialize (host) → onCreate → updateContent → postCreate → postStart, with feature-contributed onCreate / updateContent / postCreate / postStart merged on create-path. Reuse of an already-running container on `up` MUST NOT re-run onCreate / updateContent / postCreate / postStart. `up` reuse MUST still run host `initializeCommand` when a host workspace exists and MUST still follow postAttach policy. + +Create-path hook failure (onCreate, updateContent, postCreate, first-create postStart) MUST fail the command and MUST NOT leave the container for later reuse as a healthy create. Restart-class `postStartCommand` failure MUST fail the command and MUST NOT delete the container. #### Scenario: Fresh create runs full hook order -- Given a config with `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and `postStartCommand` each exiting 0 +- Given a config with `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and `postStartCommand` each exiting 0 - When the user runs `up` and no container exists for the workspace -- Then the CLI runs hooks in order **onCreate → updateContent → postCreate → postStart** via exec and `up` succeeds +- Then the CLI runs initialize on the host, then onCreate → updateContent → postCreate → postStart via exec, and `up` succeeds -#### Scenario: Reuse running skips lifecycle -- Given a matching container already running (matching config hash) +#### Scenario: Reuse running skips create-path and postStart +- Given a matching container already running (matching config hash) and a config with create-path hooks and `postStartCommand` - When the user runs `up` (no rebuild) -- Then no lifecycle hook is executed and `up` succeeds +- Then onCreate, updateContent, postCreate, and postStart are not executed +- And postAttach still follows **postAttachCommand policy (CLI-only)** -#### Scenario: Start stopped runs postStart only +#### Scenario: Start stopped runs postStart on up - Given a matching container that is stopped and a config with `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and `postStartCommand` - When the user runs `up` -- Then only `postStartCommand` runs (onCreate, updateContent, and postCreate do not run) and `up` succeeds if postStart exits 0 +- Then only resume hooks for a real start run (initialize when a host workspace exists, then postStart; onCreate, updateContent, and postCreate do not run) +- And `up` succeeds if those resume hooks exit 0 #### Scenario: Create-path hook failure deletes container - Given no existing container and a config whose `onCreateCommand` (or later create-path hook including first-create `postStartCommand`) exits non-zero @@ -40,18 +54,240 @@ The CLI MUST admit and honor these lifecycle properties in addition to existing #### Scenario: Restart postStart failure does not delete container - Given a stopped container from a prior successful create and a config whose `postStartCommand` exits non-zero -- When the user runs `up` -- Then `up` fails with a structured error for `postStartCommand` and the container still exists (MUST NOT be deleted solely due to restart postStart failure) +- When the user runs `up` or `adevcontainer start` +- Then the command fails with a structured error for `postStartCommand` and the container still exists (MUST NOT be deleted solely due to restart postStart failure) #### Scenario: Lifecycle command forms - Given `postStartCommand` as a string and `onCreateCommand` as an argv array of strings - When config is resolved -- Then both admit successfully and map to exec argv using the same shell-vs-argv rules as `postCreateCommand` +- Then both admit successfully and map using the same shell-vs-argv rules as `postCreateCommand` + +#### Scenario: Lifecycle object-map runs in parallel +- Given `onCreateCommand` as an object map with two named entries that each exit 0 +- When that stage runs +- Then both named entries run concurrently +- And the stage succeeds only after every entry exits 0 + +#### Scenario: Lifecycle object-map stage fails if any entry fails +- Given `postStartCommand` as an object map where one named entry exits non-zero +- When that stage runs on a restart path +- Then the stage fails +- And the container is not deleted solely due to that restart failure + +### Requirement: initializeCommand host execution + +The CLI MUST admit `initializeCommand` with the same string, argv-array, and object-map forms as other lifecycle commands. Invalid form MUST fail resolve with a structured error naming `initializeCommand`. Omitted or empty object-map MUST be a no-op. + +When `initializeCommand` is present, the CLI MUST run it on the **host** (not via container exec) at the start of: + +- each `adevcontainer up`, `adevcontainer clone`, and `adevcontainer rebuild` invocation when a host workspace exists, +- each `adevcontainer rebuild` of a volume-mode or clone-origin container when **no** usable host workspace exists, using a temporary host workspace root as specified below, and +- a **real start** (stopped → running) of a managed container when a host workspace exists. + +A host workspace exists when the command is operating on a bind-mode workspace, or when stamped `devcontainer.local_folder` / config identify a usable host path (including clone’s config-fetch or retained-checkout directory during `clone` / `rebuild` of a clone-origin container that still has that host path). When a usable host workspace exists, `rebuild` MUST use that path as the hook cwd and MUST NOT substitute a temporary workspace root. + +Volume-mode `adevcontainer start` with no usable host workspace MUST skip `initializeCommand` and MUST emit a warning that the host command cannot run. Guest files are not available before `start` without starting the container; `start` MUST NOT start solely to obtain them. Already-running `adevcontainer start` MUST NOT run `initializeCommand` (no start occurred). + +**Volume-mode / clone-origin rebuild with no usable host workspace.** When `initializeCommand` is present, the CLI MUST still run it on the host. The hook cwd MUST be a temporary host workspace root that contains the **current** guest config directory/files: + +- the guest `.devcontainer/` directory when that directory exists in the guest workspace, and +- the guest root `.devcontainer.json` when that file is the config. + +Those contents MUST come from the current guest workspace (the same live, possibly edited files rebuild already reads for config). The CLI MUST NOT re-fetch the git remote solely to obtain them. The temporary workspace root is **not** a full copy of the guest workspace; commands that depend on other repo-root paths (for example `./scripts/…`) are NOT required to work. A command of the form `bash .devcontainer/…` MUST be able to resolve that path from the hook cwd when the guest `.devcontainer/` directory exists. + +Absence of a guest `.devcontainer/` directory MUST NOT skip the hook: a host-global `initializeCommand` (no relative config-dir path) MUST still run, with cwd still a temporary workspace root. + +On this path the CLI MUST run `initializeCommand` after the current guest workspace is readable and **before** the old container is deleted and **before** the new container is created. After `initializeCommand` returns — success or failure — the CLI MUST remove that temporary workspace root. If removal fails, the CLI MUST emit a warning and MUST NOT fail the command solely due to that removal failure. + +Object-map entries MUST run concurrently on the host per **Lifecycle hook surface**. String vs argv invocation MUST keep the existing product rules (`sh -lc` for strings; argv without a shell). Failure of `initializeCommand` MUST fail the command with a structured error naming `initializeCommand`. On create-path, the CLI MUST NOT create the managed container if `initializeCommand` fails. On volume-mode / clone-origin `rebuild` with no usable host workspace, that failure MUST also leave the old container in place. On a real start, the CLI MUST NOT start the stopped container if `initializeCommand` fails. On `up` reuse of an already-running container, `initializeCommand` still MUST run when a host workspace exists; failure MUST fail `up` and MUST NOT stop or delete the running container. `initializeCommand` is not a create-path delete-on-fail hook. + +#### Scenario: up runs initializeCommand on the host before create +- Given a bind-mode workspace whose config has `initializeCommand` that exits 0 and no existing container +- When the user runs `adevcontainer up` +- Then the CLI runs `initializeCommand` on the host before creating the container +- And `up` continues through create-path hooks and succeeds + +#### Scenario: clone runs initializeCommand on the host checkout +- Given a cloneable repo whose config has `initializeCommand` that exits 0 +- When the user runs `adevcontainer clone ` +- Then the CLI runs `initializeCommand` on the host config-fetch or retained-checkout directory before creating the container +- And clone continues and succeeds + +#### Scenario: real bind start runs initializeCommand from stamped host path +- Given a stopped bind-mode managed container with a usable stamped host workspace and a config `initializeCommand` that exits 0 +- When the user runs `adevcontainer start --name ` +- Then the CLI runs `initializeCommand` on that host workspace before starting the container +- And then starts the container + +#### Scenario: volume-mode start without host workspace skips initializeCommand +- Given a stopped volume-mode managed container, no usable host workspace, and a config that had `initializeCommand` at create time +- When the user runs `adevcontainer start --name ` +- Then the CLI starts the container without running `initializeCommand` +- And stderr includes a warning that the host command cannot run + +#### Scenario: already-running start does not run initializeCommand +- Given a managed container that is already running and a config with `initializeCommand` +- When the user runs `adevcontainer start --name ` +- Then the command succeeds as a no-op start +- And `initializeCommand` does not run + +#### Scenario: up reuse still runs initializeCommand on the host +- Given a matching already-running bind-mode container and a config with `initializeCommand` that exits 0 +- When the user runs `adevcontainer up` +- Then the CLI runs `initializeCommand` on the host +- And onCreate / updateContent / postCreate / postStart do not run + +#### Scenario: initializeCommand failure blocks create +- Given no existing container and a config whose `initializeCommand` exits non-zero +- When the user runs `adevcontainer up` +- Then `up` fails with a structured error naming `initializeCommand` +- And no managed container is created + +#### Scenario: initializeCommand failure leaves a stopped container stopped +- Given a stopped managed container with a usable host workspace and a config whose `initializeCommand` exits non-zero +- When the user runs `adevcontainer start --name ` +- Then the command fails with a structured error naming `initializeCommand` +- And the container remains stopped + +#### Scenario: volume-mode rebuild without host workspace still runs initializeCommand +- Given a volume-mode or clone-origin managed container, no usable host workspace, a guest `.devcontainer/` directory, and a config `initializeCommand` of the form `bash .devcontainer/…` that exits 0 +- When the user runs `adevcontainer rebuild --name ` +- Then the CLI runs `initializeCommand` on the host with cwd a temporary workspace root that contains that guest `.devcontainer/` directory +- And `bash .devcontainer/…` can resolve that path from that cwd +- And the new container is created only after `initializeCommand` succeeds +- And that temporary workspace root is removed after the hook + +#### Scenario: volume-mode rebuild initialize temp is removed after failure +- Given a volume-mode or clone-origin managed container, no usable host workspace, and a config whose `initializeCommand` exits non-zero +- When the user runs `adevcontainer rebuild --name ` +- Then `rebuild` fails with a structured error naming `initializeCommand` +- And no new container is created +- And the old container remains +- And the temporary workspace root is removed after the hook + +#### Scenario: missing .devcontainer directory does not skip initializeCommand on volume rebuild +- Given a volume-mode or clone-origin managed container, no usable host workspace, a root `.devcontainer.json` as the config, no guest `.devcontainer/` directory, and a host-global `initializeCommand` that exits 0 +- When the user runs `adevcontainer rebuild --name ` +- Then the CLI still runs `initializeCommand` on the host with cwd a temporary workspace root that contains that root `.devcontainer.json` +- And the hook is not skipped solely because `.devcontainer/` is absent + +#### Scenario: volume-mode rebuild initialize is not a full workspace checkout +- Given a volume-mode or clone-origin managed container, no usable host workspace, a guest `.devcontainer/` directory, and an `initializeCommand` that only needs paths under `.devcontainer/` +- When the user runs `adevcontainer rebuild --name ` +- Then the hook runs successfully from a temporary workspace root that contains that `.devcontainer/` directory +- And success does not depend on other guest workspace paths such as `./scripts/…` being present on the host + +#### Scenario: volume-mode rebuild with a retained host checkout uses that path +- Given a clone-origin managed container whose retained host checkout is still usable and a config `initializeCommand` that exits 0 +- When the user runs `adevcontainer rebuild --name ` +- Then the CLI runs `initializeCommand` with cwd that host checkout +- And it does not substitute a temporary workspace root created solely for the hook + +### Requirement: waitFor readiness + +The CLI MUST admit `waitFor` as an enum of `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, or `postStartCommand`. Omitted `waitFor` MUST default to official `updateContentCommand`. An unknown value MUST fail resolve with a structured error naming `waitFor`. + +`waitFor` MUST control when the supporting tool may connect. The command MUST block Ready, optional vscode open, and `postAttachCommand` until the named stage **inclusive** has finished successfully. Stages after `waitFor` MAY complete in the background. The process SHOULD still wait for those remaining hooks before exiting so create-path delete-on-fail and the process exit code remain correct. + +The CLI MUST NOT emit success JSON until the `waitFor` stage has succeeded. Ready and connection hints MAY be emitted once `waitFor` is satisfied, even while later create-path hooks are still running. Optional vscode open MAY happen after `waitFor` is satisfied and MUST NOT wait for later background hooks solely to open. + +Hook order is unchanged: create-path remains initialize (host) → onCreate → updateContent → postCreate → postStart. First-create `postStartCommand` still belongs to a successful start and MUST still be initiated after `postCreateCommand`, even when `waitFor` is `updateContentCommand` and Ready MAY occur before postCreate / postStart complete. Default `waitFor` therefore means `postCreateCommand` MAY run in the background after Ready. + +On resume (real start / `up` start-stopped), create-path stages from a prior successful create are already satisfied. If `waitFor` names a create-path stage (`initializeCommand` through `postCreateCommand`), Ready / open / postAttach MUST NOT wait for those stages again. Ready / open / postAttach MUST wait for this invocation’s `postStartCommand` only when `waitFor` is `postStartCommand`; otherwise on resume they MAY occur before this invocation’s `postStartCommand`. + +Failure of a background post-`waitFor` hook MUST fail the command (non-zero) once observed. If Ready was already emitted, the process MUST still exit non-zero and MUST NOT emit a later success JSON. Create-path delete-on-fail still MUST apply to `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, and first-create `postStartCommand`. Restart-class hook failure (`postStartCommand` or `postAttachCommand` on a previously successful create) MUST NOT delete the container. + +#### Scenario: default waitFor allows Ready before postCreate +- Given a fresh create whose config omits `waitFor` and has `updateContentCommand`, `postCreateCommand`, and `postStartCommand` each exiting 0 +- When the user runs `adevcontainer up` +- Then Ready MAY be emitted after `updateContentCommand` succeeds and before `postCreateCommand` finishes +- And `postCreateCommand` then `postStartCommand` still run +- And the process does not exit 0 until those remaining hooks succeed + +#### Scenario: waitFor postCreateCommand delays Ready until postCreate +- Given a fresh create whose `waitFor` is `postCreateCommand` +- When the user runs `adevcontainer up` +- Then Ready, optional vscode open, and postAttach do not occur before `postCreateCommand` finishes +- And `postStartCommand` is still initiated after `postCreateCommand` + +#### Scenario: success JSON waits for waitFor not for later hooks +- Given a fresh create with default `waitFor` and `--json` +- When `updateContentCommand` has succeeded and `postCreateCommand` is still running +- Then the CLI MUST NOT have emitted success JSON before `updateContentCommand` succeeded +- And success JSON MAY be emitted before `postCreateCommand` finishes + +#### Scenario: background create-path hook failure still deletes +- Given a fresh create with default `waitFor` whose `postCreateCommand` exits non-zero after Ready was emitted +- When the user runs `adevcontainer up` +- Then the command exits non-zero +- And the container MUST NOT remain for later reuse as a healthy create + +#### Scenario: resume does not re-wait create-path waitFor +- Given a stopped container from a prior successful create and default `waitFor` +- When the user runs `adevcontainer up` (start-stopped) or `adevcontainer start` +- Then Ready / open / postAttach are not blocked on onCreate / updateContent / postCreate +- And Ready / open / postAttach MAY occur before this invocation’s `postStartCommand` +- And this invocation’s `postStartCommand` still runs after the container starts + +### Requirement: userEnvProbe merge + +The CLI MUST admit `userEnvProbe` as an enum of `none`, `interactiveShell`, `loginShell`, or `loginInteractiveShell`. Omitted `userEnvProbe` MUST default to official `loginInteractiveShell`. An unknown value MUST fail resolve with a structured error naming `userEnvProbe`. + +When `userEnvProbe` is not `none`, the CLI MUST probe the **remote connection user’s** shell environment inside the running container and MUST merge the probed variables into the environment of subsequent injected processes on that container: in-container lifecycle execs and `adevcontainer exec`. `none` MUST skip the probe and MUST NOT fail solely because the key is `none`. + +The probe MUST run after the container is running and before the first in-container lifecycle exec of that invocation (and before `adevcontainer exec` injects a process). Probe failure MUST fail the command with a structured error naming `userEnvProbe` and MUST NOT delete the container solely due to that failure. + +#### Scenario: default probe merges into postCreate and exec +- Given a config that omits `userEnvProbe` and a remote connection user whose login-interactive shell exports a recognizable variable +- When the user runs a fresh `up` that executes `postCreateCommand`, then runs `adevcontainer exec` +- Then both injected processes observe that probed variable + +#### Scenario: none skips probe +- Given a config with `userEnvProbe` set to `none` +- When the user runs `up` then `adevcontainer exec` +- Then the CLI does not probe the user’s shell environment +- And the command is not failed solely because probing was skipped + +#### Scenario: probe uses remote connection user not containerUser +- Given `remoteUser` `alice`, `containerUser` `bob`, and `userEnvProbe` other than `none` +- When the probe runs +- Then it probes `alice`’s shell environment, not `bob`’s + +#### Scenario: probe failure keeps the container +- Given a running or just-started container and a `userEnvProbe` other than `none` that fails +- When the command observes the probe failure +- Then the command exits non-zero with a structured error naming `userEnvProbe` +- And the container is not deleted solely due to that failure + +### Requirement: shutdownAction admission + +The CLI MUST admit `shutdownAction` as an enum so configs are not rejected solely for this property. For this image/Dockerfile product, omitted `shutdownAction` MUST default to official `stopContainer`. + +- `stopContainer` means `adevcontainer stop` stops the managed container (already required). +- `none` MUST NOT change explicit `adevcontainer stop`: `stop` is still a user command and MUST still stop the container. Last-tool-window-close auto-stop remains out of scope; the CLI MUST NOT claim to observe last-window close. +- `stopCompose` MUST fail closed with a structured error that Compose is unsupported. + +An unknown value MUST fail resolve with a structured error naming `shutdownAction`. + +#### Scenario: stopContainer config still stops on stop +- Given a running managed container whose config has `shutdownAction` `stopContainer` or omits the key +- When the user runs `adevcontainer stop` for that container +- Then the container is stopped and the command succeeds + +#### Scenario: none does not disable explicit stop +- Given a running managed container whose config has `shutdownAction` `none` +- When the user runs `adevcontainer stop` for that container +- Then the container is still stopped + +#### Scenario: stopCompose fails closed +- Given a config with `shutdownAction` `stopCompose` +- When config is resolved +- Then the CLI fails with a structured error indicating Compose is unsupported -#### Scenario: Lifecycle object (named) form admits -- Given `onCreateCommand` as an object map (e.g. `{ "shell-history": "/path/oncreate.sh" }`) with string or argv values -- When config or feature metadata is resolved -- Then admission succeeds; each named entry maps to a leaf shell/argv command and runs via exec (sequentially in sorted name order) +#### Scenario: shutdownAction presence does not fail parse +- Given an otherwise valid image config with `shutdownAction` `stopContainer` or `none` +- When config is resolved +- Then resolve succeeds ### Requirement: Lifecycle hook progress and live stream @@ -62,7 +298,7 @@ When a create-path hook, restart `postStartCommand`, or running `postAttachComma 3. Keep machine JSON on stdout pure when `--json` (or equivalent) is used — hook script stdout MUST NOT write to host stdout (tee to host stderr only). 4. Treat `ADEVCONTAINER_QUIET=1` as silencing **status lines only** (`==> Running …`); hook script output MUST still emit on host stderr under QUIET (framed as internal tool lines). -This requirement MUST NOT change hook order, admitted forms, fail/delete-on-fail policy, or postAttach gating. +This requirement MUST NOT change hook order, admitted forms, fail/delete-on-fail policy, or postAttach policy. #### Scenario: Hook run emits status and framed live-tees I/O - Given a create-path (or restart postStart / running postAttach) hook that prints to stdout and stderr and exits 0, and quiet mode unset @@ -74,5 +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) **Features progress status lines** for StatusPrinter / QUIET / `--json` norms; [terminal-output.md](terminal-output.md) for framing/color/QUIET presentation. - +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. diff --git a/specs/managed-lifecycle.md b/specs/managed-lifecycle.md index 012ed7d..109bb35 100644 --- a/specs/managed-lifecycle.md +++ b/specs/managed-lifecycle.md @@ -284,19 +284,26 @@ The CLI MUST provide `adevcontainer start` that starts a **stopped** managed con **Runtime behavior** -- If the selected container is stopped → start it via AppleContainerRuntime. -- If already running → success **no-op** (MUST NOT error solely because it was already running). +- If the selected container is stopped → start it via AppleContainerRuntime after any required host `initializeCommand`. +- If already running → success **no-op** (MUST NOT error solely because it was already running). Already-running MUST NOT run `initializeCommand` or `postStartCommand`. - MUST NOT re-clone the git URL. -- MUST NOT run the full `up` or `clone` create path (no Features rebuild, no volume re-populate, no config re-resolve required for start). +- MUST NOT run the full `up` or `clone` create path (no Features rebuild, no volume re-populate, no onCreate / updateContent / postCreate). -**Lifecycle hooks on start (locked split)** +**Lifecycle hooks on start** -| Workspace origin | `start` / start-stopped hooks | -|------------------|-------------------------------| -| **Volume-mode / clone-origin** (`devcontainer.workspace_mode=volume`) | **Runtime start only** — MUST NOT run lifecycle hooks (`postStartCommand` included) | -| **Bind-mode** via `up` | `up` start-stopped (same container, via `up` path) runs **`postStartCommand` only** per base contract. Bare `adevcontainer start` on a bind managed container is runtime start only (no config re-resolve / no hooks) in v1. | +| Workspace origin | Real start (stopped → running) | +|------------------|--------------------------------| +| **Bind-mode** | Host `initializeCommand` when a usable stamped host workspace exists; then start; then config `postStartCommand` then remelted feature postStart. Ready / open / postAttach follow [lifecycle-hooks.md](lifecycle-hooks.md) **waitFor readiness** and [vscode.md](vscode.md) **postAttachCommand policy (CLI-only)** | +| **Volume-mode / clone-origin** | Skip `initializeCommand` with a warning when no host workspace exists; start; then config `postStartCommand` then remelted feature postStart. Ready / open / postAttach follow **waitFor readiness** and **postAttachCommand policy (CLI-only)** | -Rationale: clone config may have lived only in a temp directory that is gone after clone; bare `start` MUST remain reliable without recovering full config from disk. Labels remain available for identity/list; hook re-execution on bare `start` is out of scope for v1 (use `up` for bind postStart). +`up` start-stopped MUST keep the same resume hook set (initialize when a host workspace exists, then postStart including remelted feature postStart). Bare `start` is not runtime-start-only. + +Restart-class hook failure MUST fail `start` and MUST NOT delete the container. When `start` recovery delegates to `rebuild`, rebuild’s create-path already includes postStart; the recovery path MUST NOT double-run postStart after rebuild. + +**Vscode customizations on start** + +- `adevcontainer start` MUST NOT apply `customizations.vscode.settings` or `customizations.vscode.extensions`, with or without `--vscode`. +- Config load on `start` MAY be used for hooks, open, and postAttach. It MUST NOT be used to apply settings or extensions. #### Scenario: Start stopped managed container - Given a managed container created by clone that is stopped @@ -307,16 +314,30 @@ Rationale: clone config may have lived only in a temp directory that is gone aft - Given a managed container that is already running - When the user runs `adevcontainer start --name ` - Then the command succeeds without changing the container +- And `initializeCommand` and `postStartCommand` do not run #### Scenario: Start interactive picker when multiple - Given two stopped managed containers and an interactive TTY stdin - When the user runs `adevcontainer start` without `--name` - Then the CLI presents an interactive selection UI and starts the chosen container -#### Scenario: Volume-mode start runs no hooks +#### Scenario: Volume-mode start runs postStart - Given a volume-mode managed container with labels from clone and a config that had `postStartCommand` at create time - When the user runs `adevcontainer start --name ` on a stopped container -- Then the container starts and **no** lifecycle hooks are executed on this path +- Then the container starts and `postStartCommand` runs via container exec +- And onCreate / updateContent / postCreate do not run + +#### Scenario: Bind-mode start runs postStart +- Given a stopped bind-mode managed container and a config with `postStartCommand` that exits 0 +- When the user runs `adevcontainer start --name ` +- Then the container starts and `postStartCommand` runs +- And the command succeeds + +#### Scenario: start does not apply vscode customizations +- Given a managed container whose config has well-formed settings and extensions and whose guest marker is missing or drifted +- When the user runs `adevcontainer start` without or with `--vscode` +- Then the CLI MUST NOT apply those settings or extensions on this path +- And resume hooks still follow this requirement --- diff --git a/specs/vscode.md b/specs/vscode.md index d07f8e9..7c605b1 100644 --- a/specs/vscode.md +++ b/specs/vscode.md @@ -2,7 +2,7 @@ ## Purpose -VS Code integration: manual attach acceptance, optional `--vscode` best-effort open, postAttachCommand gating after successful open, and CLI apply of config-file `customizations.vscode` settings/extensions (soft-fail, idempotent marker). +VS Code integration: manual attach acceptance, optional `--vscode` best-effort open, postAttachCommand CLI-attach policy, and CLI apply of config-file `customizations.vscode` settings/extensions (soft-fail, idempotent marker). ## Requirements @@ -14,7 +14,7 @@ MVP acceptance for editor integration is: 2. **Optional best-effort open (additive):** When the user passes `--vscode` on `up`, `start`, `clone`, or `rebuild`, the CLI MUST attempt a best-effort open of a new VS Code window on the resolved remote workspace folder per **VS Code best-effort open**. Open failure MUST be soft (warn; lifecycle success preserved **by itself**). Without `--vscode`, no automatic open is required. -3. **CLI attach hook for postAttach:** A successful best-effort open under `--vscode` is the product’s CLI attach hook for gating `postAttachCommand` (see **postAttachCommand policy (CLI-only)**). This is an approximation of IDE attach, not confirmation that the remote session is fully ready. +3. **CLI attach hook for postAttach:** The CLI is the supporting tool. `postAttachCommand` runs per **postAttachCommand policy (CLI-only)** — at the end of successful `up` / `clone` / `rebuild`, after a real `start`, and on already-running `start` only after successful `--vscode` open. A successful best-effort open is an additional tool attach, not the sole gate, and MUST NOT be required for CLI-attach paths. This is an approximation of IDE attach, not confirmation that the remote session is fully ready. 4. **CLI apply of config-file vscode customizations (additive):** The CLI MUST apply parseable config-file `customizations.vscode.settings` on create-path (not gated on open) and MUST apply parseable `customizations.vscode.extensions` when `--vscode` is set (before open; not gated on open success), per the apply requirements. Manual attach without `--vscode` does not receive CLI extension install. Apply failures are soft-fail and MUST NOT be presented as full Dev Containers parity. @@ -46,9 +46,11 @@ The CLI MUST accept an optional boolean flag `--vscode` on: - `adevcontainer clone` - `adevcontainer rebuild` -When `--vscode` is **absent**, those commands MUST behave as today for editor open (no automatic editor open). When `--vscode` is **present**, after the command’s container lifecycle succeeds and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder** (see VS Code best-effort open). postAttach gating after that open is specified under **postAttachCommand policy (CLI-only)**. +When `--vscode` is **absent**, those commands MUST NOT invoke a host VS Code open. When `--vscode` is **present**, after the command’s container lifecycle has reached the `waitFor` connection point and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder**. postAttach after that open is specified under **postAttachCommand policy (CLI-only)**. -On `rebuild`, `--vscode` behavior MUST be identical to the `up`/`clone` create path: after rebuild lifecycle success on the new container, run extensions apply (flag gate only), then attempt a best-effort open; on open **success**, run the postAttach gate; on open **soft-fail**, skip postAttach with status when present (extensions may already have run) — never failing rebuild solely due to open. +`--vscode` MUST NOT gate settings apply or extensions apply. On `start`, the flag still requests open (and postAttach only when open succeeds on an already-running container); `start` MUST NOT apply customizations. On CLI-attach paths, omitting `--vscode` MUST NOT skip postAttach. + +On `rebuild`, `--vscode` behavior MUST be identical to the `up`/`clone` create path for open: after rebuild lifecycle has reached the `waitFor` connection point on the new container, attempt a best-effort open. postAttach follows **postAttachCommand policy (CLI-only)**. Open MUST NOT fail rebuild solely due to open. Unknown or misspelled variants that are not the product flag MUST continue to fail closed per existing usage rules. `--vscode` MUST be combinable with other valid flags for those commands (including `--json` where applicable). @@ -76,11 +78,26 @@ Unknown or misspelled variants that are not the product flag MUST continue to fa - Then after lifecycle success the CLI attempts to open a new VS Code window on the resolved remote workspace folder - And rebuild still reports success when open succeeds and postAttach is absent or exits 0 -#### Scenario: without --vscode behavior unchanged +#### Scenario: --vscode still only gates open not apply on up +- Given a successful `up` create-path with well-formed settings and extensions and a config that also has `postAttachCommand` +- When the user runs `up` **without** `--vscode` +- Then settings and extensions apply still run per the apply requirements +- And the CLI MUST NOT invoke a host VS Code open +- And postAttach MUST execute as CLI attach + +#### Scenario: --vscode on already-running start opens without applying customizations +- Given a managed container that is already running and a config with settings, extensions, and `postAttachCommand` +- When the user runs `start --vscode` and host `code` launch succeeds +- Then after start success the CLI attempts to open a new VS Code window attached to that container +- And postAttach runs after that successful open +- And the CLI MUST NOT apply settings or extensions on that `start` invocation +- And `postStartCommand` does not run + +#### Scenario: without --vscode behavior unchanged for open - Given any valid `up`, `start`, `clone`, or `rebuild` invocation - When the user omits `--vscode` - Then the CLI MUST NOT invoke a host VS Code open as part of that command -- And manual attach (list/inspect + experimental Attach to Running Apple Container) remains valid +- And manual attach remains valid #### Scenario: --json works with --vscode - Given a successful `up`, `clone`, or `rebuild` with both `--json` and `--vscode` @@ -107,8 +124,9 @@ When `--vscode` is set and lifecycle has succeeded, the CLI MUST attempt to open - If no usable VS Code CLI (`code`) is found, or the open/launch fails for any reason (including missing id, image, or folder inputs needed to build the URI), the CLI MUST: - Emit a clear warning on stderr (naming the missing dependency or failure at a high level), and - **MUST NOT** change the lifecycle command’s success exit solely because open failed, and - - **MUST NOT** tear down or alter the container as a consequence of open failure, and - - **MUST NOT** execute postAttach solely because of that soft-failed open (see postAttachCommand policy). + - **MUST NOT** tear down or alter the container as a consequence of open failure. +- On a path that would otherwise run postAttach as CLI attach (`up` / `clone` / `rebuild` / real `start`), open soft-fail MUST NOT prevent postAttach. +- On already-running `start`, open soft-fail MUST NOT by itself execute postAttach (there was no CLI attach and no successful tool open). - The product MAY warn when `code` is missing; it MUST NOT hard-require VS Code for `up` / `start` / `clone` / `rebuild` success. **Host prerequisites (document; soft):** @@ -125,28 +143,27 @@ When `--vscode` is set and lifecycle has succeeded, the CLI MUST attempt to open **Approximation (document):** -- Successful host `code` launch is a **CLI-initiated attach approximation**. The CLI MUST NOT wait for VS Code Server fully ready or for IDE-confirmed remote attach before treating open as success for postAttach gating. Detecting manual UI attach is out of scope. +- Successful host `code` launch remains a CLI-initiated attach approximation. The CLI MUST NOT wait for VS Code Server fully ready. Detecting manual UI attach is out of scope. #### Scenario: omitted workspaceFolder uses product default already resolved - Given a config that omits `workspaceFolder` (or leaves it empty) so resolve yields the product default `/workspaces/` - When the user runs `up` or `clone` with `--vscode` after successful lifecycle - Then the open targets that already-resolved default folder (e.g. `/workspaces/`), not an empty path and not a re-parse of raw JSON that bypasses the resolver -#### Scenario: soft-fail when code CLI missing -- Given lifecycle would otherwise succeed and `--vscode` is set +#### Scenario: soft-fail when code CLI missing on CLI-attach path +- Given lifecycle would otherwise succeed on `up` and `--vscode` is set and `postAttachCommand` exits 0 - When no usable `code` executable is discoverable on the host -- Then the command still exits successfully for the lifecycle outcome (when postAttach does not run) +- Then the command still attempts `postAttachCommand` - And a stderr warning indicates that VS Code open was skipped or failed because `code` was not found - And the managed container remains running / created as the lifecycle commanded -- And postAttach MUST NOT execute -#### Scenario: soft-fail when launch fails -- Given lifecycle success, `--vscode` set, and a discoverable `code` that fails when invoked for open +#### Scenario: soft-fail when launch fails on already-running start +- Given an already-running container, `--vscode` set, and a discoverable `code` that fails when invoked for open - When open/launch returns failure -- Then the lifecycle command still reports success (when postAttach does not run) +- Then the lifecycle command still reports success - And a stderr warning indicates the open failure +- And `postAttachCommand` MUST NOT execute - And the managed container is not deleted or stopped solely due to that failure -- And postAttach MUST NOT execute #### Scenario: explicit workspaceFolder is honored for open - Given a config with an explicit resolved `workspaceFolder` (e.g. `/custom/ws`) @@ -168,85 +185,101 @@ When `--vscode` is set and lifecycle has succeeded, the CLI MUST attempt to open ### Requirement: postAttachCommand policy (CLI-only) -The CLI MUST parse and admit `postAttachCommand` when present (string, argv array, or object map of name → string|argv — same `LifecycleCommand` forms as other hooks) so configs are not rejected solely for this property. Invalid form MUST still fail resolve with a structured error naming `postAttachCommand` (unchanged). +The CLI MUST parse and admit `postAttachCommand` when present (string, argv array, or object map of name → string|argv — same forms as other hooks) so configs are not rejected solely for this property. Invalid form MUST still fail resolve with a structured error naming `postAttachCommand`. Object-map entries MUST run concurrently per [lifecycle-hooks.md](lifecycle-hooks.md) **Lifecycle hook surface**. + +This policy is a **CLI attach model**. The CLI is the supporting tool. Manual IDE UI attach without the CLI remains out of scope. `adevcontainer exec` is **not** attach and MUST NOT run `postAttachCommand`. The product MUST NOT require IDE-confirmed remote ready and MUST NOT wait for VS Code Server fully ready. **When postAttach RUNS** -The CLI MUST execute postAttach only when **all** of the following hold on `up`, `start`, `clone`, or `rebuild`: +The CLI MUST execute postAttach when **any** of the following hold after the command’s prior lifecycle steps required by `waitFor` have succeeded: -1. `--vscode` is set, and -2. The best-effort VS Code open outcome is **success** (host `code` launch succeeded per **VS Code best-effort open**). +1. Successful `adevcontainer up`, `adevcontainer clone`, or `adevcontainer rebuild` (including `up` reuse of an already-running matching container) — the CLI attach at the end of that supporting-tool command. +2. A **real** `adevcontainer start` of a previously stopped container. +3. Already-running `adevcontainer start` **only when** `--vscode` is set **and** best-effort open succeeds — that open is an actual tool attach. -That successful open is the **CLI attach hook**. Execution MUST occur **after** the successful open attempt completes — never before open when `--vscode` is set. +When `--vscode` is set on a path that already qualifies as CLI attach (items 1–2), postAttach MUST still run **after** the open attempt. If that open **soft-fails**, the CLI MUST still run postAttach (open is best-effort and MUST NOT suppress the CLI attach). When `--vscode` is set and open **succeeds**, postAttach MUST run after that successful open. **What runs** -When the run gate is satisfied, the CLI MUST run: - -- Config `postAttachCommand` when present, then -- Feature-contributed postAttach commands (`featurePostAttachCommands` / equivalent merge), in the same merge/order patterns as other feature lifecycle hooks already in product (LifecycleRunner conventions: config hook then feature hooks for that stage). - -Each command MUST execute via existing container **exec** lifecycle machinery (same string/argv/object-map rules as `postCreateCommand` / `postStartCommand`; object-map entries sequential sorted-by-name), using the **resolved remote connection user** (from config resolution on create-path, or stamped/label-aligned user on reuse/`start`) and the resolved workspace folder when set — not create-only `containerUser` when `remoteUser` differs. When `remoteUser` is `alice` and `containerUser` is `bob`, postAttach MUST use `alice`. +When the run gate is satisfied, the CLI MUST run config `postAttachCommand` when present, then feature-contributed postAttach commands, using the resolved remote connection user and workspace folder when set. When `remoteUser` is `alice` and `containerUser` is `bob`, postAttach MUST use `alice`. **When postAttach is SKIPPED (status line, not executed)** -- **`--vscode` absent** (manual attach path / no CLI attach): if any postAttach is present (config and/or features), the CLI MUST emit a single stderr status line indicating attach is not hooked (e.g. `postAttach skipped (no attach hook)` or a clearer equivalent) and MUST NOT execute postAttach. -- **`--vscode` set but open soft-failed or skipped** (missing `code`, launch fail, missing id/image/folder, or other soft-fail open outcome): the CLI MUST NOT execute postAttach. The CLI SHOULD emit a skip status explaining that attach open did not succeed (in addition to the open soft-fail warning as applicable). -- **No postAttach present** (config and features empty): the CLI MUST NOT emit a postAttach skip line. +- Already-running `start` without a successful `--vscode` open: if any postAttach is present, emit a single stderr skip status and MUST NOT execute postAttach. +- `--vscode` set on already-running `start` and open soft-failed or skipped: MUST NOT execute postAttach; SHOULD emit a skip status that attach open did not succeed. +- No postAttach present: MUST NOT emit a postAttach skip line. **Failure policy** -- If postAttach **runs** and any postAttach command exits non-zero, the lifecycle command (`up` / `start` / `clone` / `rebuild`) MUST fail (non-zero) with a clear structured error naming postAttach (property label consistent with other lifecycle hooks, including feature-labeled forms when applicable). -- The CLI MUST NOT delete or stop the container solely due to postAttach failure (container already successfully brought up; VS Code may already be opening). This contrasts with create-path onCreate / updateContent / postCreate / first-create postStart delete-on-fail. On `rebuild`, a non-zero postAttach MUST keep the **new** container and MUST NOT start a volume or bind recovery session. -- Open soft-fail still MUST NOT fail the lifecycle command **by itself**. postAttach failure after successful open **does** fail the lifecycle exit. +- If postAttach runs and any postAttach command exits non-zero, the lifecycle command MUST fail (non-zero) with a structured error naming postAttach. +- The CLI MUST NOT delete or stop the container solely due to postAttach failure. On `rebuild`, a non-zero postAttach MUST keep the **new** container and MUST NOT start a recovery session. +- Open soft-fail still MUST NOT fail the lifecycle command **by itself**. - On postAttach failure, the command MUST follow the existing error path (no success JSON on stdout for `--json` paths). -**Approximation caveat** - -Running postAttach after successful host `code` launch is a **CLI-initiated attach approximation**. The product MUST NOT require IDE-confirmed remote ready, MUST NOT wait for VS Code Server fully ready, and MUST NOT treat manual UI attach as a postAttach trigger. Full IDE attach event integration remains out of scope beyond this gate. - **Consistency** -The gated policy MUST apply consistently on `up`, `start`, `clone`, and `rebuild`. Presence of `postAttachCommand` alone MUST NOT fail those commands when postAttach is skipped. +Presence of `postAttachCommand` alone MUST NOT fail those commands when postAttach is skipped. vscode customizations apply on `start` remains forbidden. + +#### Scenario: postAttach runs at end of up without --vscode +- Given a valid config with `postAttachCommand` that exits 0 and a successful `up` (fresh, reuse, or start-stopped) +- When the user runs `up` without `--vscode` +- Then the CLI executes `postAttachCommand` via container exec after waitFor is satisfied +- And the command reports lifecycle success when postAttach exits 0 + +#### Scenario: postAttach runs after real start without --vscode +- Given a stopped managed container, default `waitFor`, and a config with `postAttachCommand` that exits 0 +- When the user runs `adevcontainer start --name ` without `--vscode` +- Then after the real start, once waitFor is satisfied, the CLI executes `postAttachCommand` +- And that MAY be before this invocation’s `postStartCommand` +- And the command succeeds + +#### Scenario: already-running start skips postAttach without successful open +- Given a managed container that is already running and a config with `postAttachCommand` that would exit non-zero if run +- When the user runs `adevcontainer start --name ` without `--vscode` +- Then `postAttachCommand` does not run +- And stderr includes a one-time skip status +- And the command succeeds + +#### Scenario: already-running start runs postAttach after successful --vscode open +- Given an already-running managed container, `postAttachCommand` that exits 0, and `--vscode` whose host `code` launch succeeds +- When the user runs `adevcontainer start … --vscode` +- Then after the successful open the CLI executes `postAttachCommand` +- And `initializeCommand` and `postStartCommand` do not run + +#### Scenario: open soft-fail does not suppress CLI-attach postAttach +- Given a config with `postAttachCommand` present and a successful `up` / `clone` / `rebuild` or real `start` +- When the user runs that command with `--vscode` and open soft-fails +- Then the CLI still executes `postAttachCommand` +- And open soft-fail does not by itself fail the command +- And the managed container is not deleted or stopped solely due to open soft-fail -#### Scenario: postAttach runs after successful --vscode open -- Given a valid config with `postAttachCommand` that exits 0, and a successful container lifecycle on `up` (or equivalently `start` / `clone` / `rebuild`) -- When the user runs the command with `--vscode` and host `code` launch succeeds (or mocks equivalent) -- Then after the successful open the CLI executes `postAttachCommand` via container exec +#### Scenario: postAttach still runs after successful --vscode open on CLI-attach paths +- Given a valid config with `postAttachCommand` that exits 0 and a successful container lifecycle on `up` (or equivalently real `start` / `clone` / `rebuild`) +- When the user runs the command with `--vscode` and host `code` launch succeeds +- Then after the successful open the CLI executes `postAttachCommand` - And the command reports lifecycle success -- And success JSON shape (when `--json`) remains unchanged - -#### Scenario: postAttach skipped without --vscode -- Given a valid minimal image config that also sets `postAttachCommand` to a command that would exit non-zero if run -- When the user runs `up` (fresh create) without `--vscode` -- Then `up` succeeds without executing `postAttachCommand` -- And stderr includes a one-time skip status for postAttach (e.g. no attach hook) - -#### Scenario: postAttach skipped when open soft-fails -- Given a config with `postAttachCommand` present and lifecycle that would otherwise succeed -- When the user runs `up` (or `start` / `clone` / `rebuild`) with `--vscode` and open soft-fails (missing `code`, launch failure, or missing open inputs) -- Then the CLI MUST NOT execute `postAttachCommand` -- And the lifecycle command still exits successfully -- And stderr includes open soft-fail warning and SHOULD include a postAttach skip status explaining attach open did not succeed -- And the managed container is not deleted or stopped solely due to open soft-fail -- And on rebuild, no recovery helper or editor session is created #### Scenario: postAttach failure fails command but keeps container -- Given lifecycle success, `--vscode` set, successful open, and `postAttachCommand` that exits non-zero +- Given a CLI-attach path that runs postAttach and `postAttachCommand` exits non-zero - When the user runs `up` (or `start` / `clone` / `rebuild`) - Then the command fails with a structured error naming postAttach -- And the managed container still exists and is not deleted or stopped solely due to that postAttach failure -- And on rebuild, no recovery helper or editor session is created +- And the managed container still exists and is not deleted or stopped solely due to that failure +- And on rebuild, no recovery session is created - And no success JSON is emitted on the error path -#### Scenario: feature postAttach runs after successful open -- Given resolved config with feature-contributed postAttach commands (and optional config `postAttachCommand`) and successful open under `--vscode` +#### Scenario: feature postAttach runs on CLI attach +- Given resolved config with feature-contributed postAttach commands (and optional config `postAttachCommand`) on a CLI-attach path - When postAttach runs -- Then feature postAttach commands execute via container exec after the config hook when both are present (same merge/order spirit as other feature lifecycle hooks) +- Then feature postAttach commands execute via container exec after the config hook when both are present - And non-zero exit of a feature postAttach fails the command under the same keep-container failure policy as config postAttach +#### Scenario: exec is not attach +- Given a running managed container and a config with `postAttachCommand` +- When the user runs `adevcontainer exec` +- Then `postAttachCommand` does not run + #### Scenario: Invalid postAttach form still fails resolve -- Given `postAttachCommand` set to a non-string, non-array value +- Given `postAttachCommand` set to a non-string, non-array, non-object value - When config is resolved - Then the CLI fails with a structured error naming `postAttachCommand` @@ -256,7 +289,7 @@ The gated policy MUST apply consistently on `up`, `start`, `clone`, and `rebuild - Then the CLI MUST NOT emit a postAttach skip status line solely for postAttach #### Scenario: postAttach runs as remote connection user not containerUser -- Given `remoteUser` `alice`, `containerUser` `bob`, `--vscode`, and successful open +- Given `remoteUser` `alice`, `containerUser` `bob`, and a CLI-attach path that runs postAttach - When postAttach runs - Then postAttach exec uses user `alice` diff --git a/wiki/architecture.md b/wiki/architecture.md index d048906..2c60a28 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -117,7 +117,7 @@ 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) + active [`align-official-lifecycle`](../specs/changes/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/). Matrix: | Path | Hooks | |------|--------| @@ -127,7 +127,7 @@ Effective user for `exec`, lifecycle hooks, and VS Code attach defaults (not alw | 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 | | 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 [`align-official-lifecycle`](../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../specs/changes/vscode-customizations-up-clone-rebuild/); 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/) | + | `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/) | - **runArgs allowlist** and **hostRequirements** enforce+apply: [cli-runtime-boundary.md](conventions/cli-runtime-boundary.md). Contract: [`specs/runargs-host.md`](../specs/runargs-host.md). - Long-lived devcontainers use keep-alive entrypoint **`/bin/sleep` infinity** so the container stays up for `exec`/attach. @@ -195,7 +195,7 @@ code --new-window --folder-uri "vscode-remote://apple-container+${HEX}${FOLDER}" - Extension UI command `remote-containers.attachToAppleContainer` opens the **remote authority only** (no folder) → empty/no-folder window UX gap; the `--folder-uri` recipe avoids that. - **nameConfig** (attach defaults): write `~/Library/Application Support/Code/User/globalStorage/ms-vscode-remote.remote-containers/nameConfigs/.json` with `workspaceFolder` + `remoteUser` (from non-empty connection-user resolution / stamp) **before** launching `code`. Apple attach **ignores** nameConfig `remoteUser` for the integrated terminal (uses container default user) — create `-u` compensation covers that; nameConfig still written for other attach defaults. Folder path in the URI alone does not set remote user. -Not full Dev Containers up/rebuild or IDE-owned customizations parity; volume-mode is product `clone`, not the extension’s clone-in-volume. Contract: [`specs/vscode.md`](../specs/vscode.md) + active [`align-official-lifecycle`](../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../specs/changes/vscode-customizations-up-clone-rebuild/); 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/). Gaps: [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md). +Not full Dev Containers up/rebuild or IDE-owned customizations parity; volume-mode is product `clone`, not the extension’s clone-in-volume. 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/). Gaps: [devcontainer-apple-gaps.md](domain/devcontainer-apple-gaps.md). ## Reference config diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 554774e..e5a3493 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -321,7 +321,7 @@ Presentation stack (StatusPrinter + TerminalStyle, tool `| ` framing, QUIET/colo ## Lifecycle execution (hook matrix) -In-container hooks run via runtime **exec** (effective user + workspace folder when set). Host `initializeCommand` is host-process, not exec. Usable host workspace (bind / clone checkout) is cwd when present. Volume/clone-origin `rebuild` with no host workspace still runs: cwd a temp root that contains the live guest `.devcontainer/` (and root `.devcontainer.json` if that is the config); temp removed after the hook; `./scripts/…` not required. Volume `start` with no host path still skip+warn. Omitted properties and empty `{}` maps are no-ops. Nested objects rejected. `waitFor` default `updateContentCommand`. `userEnvProbe` admitted (default `loginInteractiveShell`; `none` skips). `shutdownAction` admitted (`stopCompose` fail-closed; explicit `stop` always stops). Exec env PATH expansion applies (see PATH expansion). **Live I/O:** lifecycle exec enables streamOutput — child stdout+stderr teed live to host stderr framed as internal tool lines (` | ` display; raw capture); status `==> Running …` is separate StatusPrinter output. Presentation: [terminal-output.md](terminal-output.md). Contract: [`specs/lifecycle-hooks.md`](../../specs/lifecycle-hooks.md) + active [`align-official-lifecycle`](../../specs/changes/align-official-lifecycle/). +In-container hooks run via runtime **exec** (effective user + workspace folder when set). Host `initializeCommand` is host-process, not exec. Usable host workspace (bind / clone checkout) is cwd when present. Volume/clone-origin `rebuild` with no host workspace still runs: cwd a temp root that contains the live guest `.devcontainer/` (and root `.devcontainer.json` if that is the config); temp removed after the hook; `./scripts/…` not required. Volume `start` with no host path still skip+warn. Omitted properties and empty `{}` maps are no-ops. Nested objects rejected. `waitFor` default `updateContentCommand`. `userEnvProbe` admitted (default `loginInteractiveShell`; `none` skips). `shutdownAction` admitted (`stopCompose` fail-closed; explicit `stop` always stops). Exec env PATH expansion applies (see PATH expansion). **Live I/O:** lifecycle exec enables streamOutput — child stdout+stderr teed live to host stderr framed as internal tool lines (` | ` display; raw capture); status `==> Running …` is separate StatusPrinter output. Presentation: [terminal-output.md](terminal-output.md). Contract: [`specs/lifecycle-hooks.md`](../../specs/lifecycle-hooks.md); archive [`20260814-align-official-lifecycle`](../../specs/changes/archive/20260814-align-official-lifecycle/). ### Command forms (`LifecycleCommand`) @@ -345,7 +345,7 @@ Each hook property admits **string** | **argv `string[]`** | **object map** `nam | Bind start-stopped (`up`, hash match) | host `initializeCommand` → `postStartCommand` + feature remelt; then settings+extensions apply if pending (**not** `--vscode`-gated); CLI-attach postAttach | | 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 | | `customizations.vscode` | **CLI apply** config-file v1 (`VSCodeCustomizationsApply`): settings+extensions by default on `up`/`clone`/`rebuild` (create-path + `up` reuse / `up` start-stopped; **not** gated on `--vscode` or open; **not** on `start`; marketplace VSIX for **guest** `targetPlatform` linux/alpine × arm64/x64 via `uname -m`+os-release; platform-specific asset URL `?targetPlatform=` (universal omits — 404 with query); unknown arch soft-fail no host VSIX → tar-pipe → unzip → **`extensions.json` registry upsert** + cache invalidate; `metadata.pinned` false bare / true `@version`; BFS **`extensionDependencies` ∪ `extensionPack`** shared cycle guard; soft-fail per ID; seed ≠ EH/activation/`runtimeDependencies`). Order with flag on apply-commands: apply → open → postAttach (postAttach still runs if open soft-fails). Soft-fail apply ≠ postAttach fail-keep. Marker `$HOME/.adevcontainer/vscode-customizations.applied` (config payload hash only; finalize does not require `--vscode`/open; `start` never writes). Not image build; not feature/metadata merge; Apple attach does not auto-install. Detail: [architecture.md — VS Code flow](../architecture.md#vs-code-flow); [gaps — CLI extension seed](../domain/devcontainer-apple-gaps.md#cli-extension-seed-vs-full-marketplace-install) | -| `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 if no flag or open soft-fails (no status line if absent). Order on apply-commands: apply → open → postAttach. Contract: [`specs/vscode.md`](../../specs/vscode.md) + active [`align-official-lifecycle`](../../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../../specs/changes/vscode-customizations-up-clone-rebuild/); 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/) | +| `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 if no flag or open soft-fails (no status line if absent). Order on apply-commands: apply → open → postAttach. 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/) | - Capture exit codes; failed hook fails the command — do not pretend success. - **Create-path failure** (any of onCreate / updateContent / postCreate / postStart on fresh create): delete the container **before** returning failure, so reuse cannot treat a half-bootstrapped container as healthy. Customizations apply is **not** part of create-path delete-on-fail. After delete-on-fail, `up`/`clone` may enter [bring-up recovery](#bring-up-recovery-bringuprecovery) when an editable config exists. diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index 8839fb6..7ed50b2 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -134,7 +134,7 @@ Detail: [cli-runtime-boundary — Features runner](../conventions/cli-runtime-bo ### VS Code attach (`--vscode` + manual) -After lifecycle success, `up` / `start` / `clone` / `rebuild` accept **`--vscode`**: best-effort host `code --new-window --folder-uri …`. Missing `code` or launch fail → stderr warn; open alone does not fail the command. **`--vscode` = open only** — not settings/extensions apply, not postAttach (except already-running `start`). Without the flag, no open; CLI-attach postAttach still runs on `up`/`clone`/`rebuild`/real start. Same URI recipe works manually (manual attach is **not** an apply trigger). On `up`/`clone`/`rebuild` with `--vscode`: **apply → open**; postAttach is CLI attach after waitFor (open soft-fail does not skip). On `start`: never apply; real start CLI-attach postAttach; already-running postAttach only after successful open. Full recipe + apply policy: [architecture.md — VS Code flow](../architecture.md#vs-code-flow). Contract: [`specs/vscode.md`](../../specs/vscode.md) + active [`align-official-lifecycle`](../../specs/changes/align-official-lifecycle/) + active [`vscode-customizations-up-clone-rebuild`](../../specs/changes/vscode-customizations-up-clone-rebuild/); 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/). +After lifecycle success, `up` / `start` / `clone` / `rebuild` accept **`--vscode`**: best-effort host `code --new-window --folder-uri …`. Missing `code` or launch fail → stderr warn; open alone does not fail the command. **`--vscode` = open only** — not settings/extensions apply, not postAttach (except already-running `start`). Without the flag, no open; CLI-attach postAttach still runs on `up`/`clone`/`rebuild`/real start. Same URI recipe works manually (manual attach is **not** an apply trigger). On `up`/`clone`/`rebuild` with `--vscode`: **apply → open**; postAttach is CLI attach after waitFor (open soft-fail does not skip). On `start`: never apply; real start CLI-attach postAttach; already-running postAttach only after successful open. Full recipe + apply policy: [architecture.md — VS Code flow](../architecture.md#vs-code-flow). 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/). | Piece | Fact | |-------|------| From 3ae8d8624b250a2d2aa957a4e8f065a569e3e4a0 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 14 Aug 2026 16:57:13 +0000 Subject: [PATCH 7/7] docs: restore archived vscode customizations spec --- .../proposal.md | 12 ++-- .../spec.md | 72 ++++++++++--------- .../tasks.md | 0 3 files changed, 44 insertions(+), 40 deletions(-) rename specs/changes/{vscode-customizations-up-clone-rebuild => archive/20260814-vscode-customizations-up-clone-rebuild}/proposal.md (68%) rename specs/changes/{vscode-customizations-up-clone-rebuild => archive/20260814-vscode-customizations-up-clone-rebuild}/spec.md (87%) rename specs/changes/{vscode-customizations-up-clone-rebuild => archive/20260814-vscode-customizations-up-clone-rebuild}/tasks.md (100%) diff --git a/specs/changes/vscode-customizations-up-clone-rebuild/proposal.md b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md similarity index 68% rename from specs/changes/vscode-customizations-up-clone-rebuild/proposal.md rename to specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md index 4b9dd89..ac9961d 100644 --- a/specs/changes/vscode-customizations-up-clone-rebuild/proposal.md +++ b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/proposal.md @@ -2,7 +2,7 @@ ## Intent -`customizations.vscode` settings already apply on create-path and on `up` reuse / start-stopped without `--vscode`, but extensions still wait for that flag, and `adevcontainer start` can still repair settings or install pending extensions. Users who bring a container up, clone, or rebuild expect declared settings and extensions to be present for a later manual attach, without opting into editor open. This change makes both apply by default on `up`, `clone`, and `rebuild`. Bare `start` still MUST NOT apply settings or extensions; resume hooks follow the realized official lifecycle (not a runtime-only lock). +`customizations.vscode` settings already apply on create-path and on `up` reuse / start-stopped without `--vscode`, but extensions still wait for that flag, and `adevcontainer start` can still repair settings or install pending extensions. Users who bring a container up, clone, or rebuild expect declared settings and extensions to be present for a later manual attach, without opting into editor open. This change makes both apply by default on `up`, `clone`, and `rebuild`, and keeps bare `start` a runtime start only. ## Scope @@ -11,13 +11,13 @@ - **MODIFY** [vscode.md](../../vscode.md) **VS Code attach acceptance**, **Optional `--vscode` flag on up, start, clone, and rebuild**, **Apply vscode settings on create-path (and repair on drift)**, and **Vscode customizations apply idempotency** so `--vscode` no longer gates apply - **REMOVE** [vscode.md](../../vscode.md) **Apply vscode extensions when --vscode is set (before open)** and **ADD** **Apply vscode extensions on up, clone, and rebuild** (same guest install mechanism; new command gate) - **MODIFY** [core.md](../../core.md) editor customizations property surface, **No longer pure-ignore** apply references, parseable-apply scenario, and the **Up lifecycle** vscode customizations matrix -- **MODIFY** [managed-lifecycle.md](../../managed-lifecycle.md) **Start managed container** so `adevcontainer start` MUST NOT apply settings or extensions (with or without `--vscode`). Resume hooks stay as in the realized spec (this change does **not** lock `start` as runtime-only / no postStart). -- Unchanged and in force: realized **postAttachCommand policy (CLI-only)** (CLI attach model); realized start hooks (`postStart` on every real start); identity hash still excludes customizations; Apple attach still does not auto-install; apply remains guest-side, soft-fail, marker-idempotent, and not image/Features bake +- **MODIFY** [managed-lifecycle.md](../../managed-lifecycle.md) **Start managed container** so `adevcontainer start` MUST NOT apply settings or extensions (with or without `--vscode`) while remaining runtime start only +- Unchanged and in force: postAttach still runs only after successful `--vscode` open; bare `start` still MUST NOT run create-path hooks or `postStartCommand`; bind start-stopped `postStart` remains an `up` path; identity hash still excludes customizations; Apple attach still does not auto-install; apply remains guest-side, soft-fail, marker-idempotent, and not image/Features bake ## Non-goals -- Changing realized **postAttachCommand policy** (CLI attach model stays) -- Changing realized start hooks (`postStart` on every real start stays; this change only excludes vscode customizations apply on `start`) +- Changing **postAttachCommand policy** (still `--vscode` + successful open; fail-keep; skip status when present) +- Adding `postStartCommand` or other create-path hooks to bare `adevcontainer start` (bind start-stopped postStart stays on `up`) - Changing `--vscode` open behavior, nameConfig, folder-uri, or soft-fail open - Baking extensions or settings into the image, Features Dockerfile, or derived-image identity - Feature-contributed or image `devcontainer.metadata` customizations merge @@ -32,4 +32,4 @@ Lite SDD: this proposal + outcome delta `spec.md` only (no `design.md`, no `tasks.md` in this propose step). -On `up`, `clone`, and `rebuild`, after that command’s own lifecycle succeeds and the managed container is running, apply parseable config-file settings and extensions by default — including `up` reuse and `up` start-stopped — without requiring `--vscode`. On those commands, `--vscode` continues only to request best-effort open; postAttach follows the realized CLI attach model. `adevcontainer start` starts (or no-ops) the selected container, runs realized resume hooks, and, when `--vscode` is set, may still open; it MUST NOT apply settings or extensions. Keep the existing running-guest apply path, marker, and soft-fail policy so identity and image build stay unchanged. +On `up`, `clone`, and `rebuild`, after that command’s own lifecycle succeeds and the managed container is running, apply parseable config-file settings and extensions by default — including `up` reuse and `up` start-stopped — without requiring `--vscode`. On those commands, `--vscode` continues only to request best-effort open and, on open success, postAttach. `adevcontainer start` starts (or no-ops) the selected container and, when `--vscode` is set, may still open and run postAttach; it MUST NOT apply settings or extensions. Keep the existing running-guest apply path, marker, and soft-fail policy so identity and image build stay unchanged. diff --git a/specs/changes/vscode-customizations-up-clone-rebuild/spec.md b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md similarity index 87% rename from specs/changes/vscode-customizations-up-clone-rebuild/spec.md rename to specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md index e6e612d..a47f762 100644 --- a/specs/changes/vscode-customizations-up-clone-rebuild/spec.md +++ b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/spec.md @@ -1,6 +1,6 @@ # Change Spec: vscode-customizations-up-clone-rebuild -Delta against realized contract (union of `specs/.md`). RFC 2119 keywords apply. `--vscode` open and realized **postAttachCommand policy (CLI-only)** (CLI attach model) remain in force; this change MUST NOT re-gate postAttach on `--vscode` or lock `start` as runtime-only / no postStart. **Vscode customizations apply is not image build** remains in force unchanged. `start` MUST NOT apply vscode customizations. +Delta against realized contract (union of `specs/.md`). RFC 2119 keywords apply. `--vscode` open and **postAttachCommand policy (CLI-only)** remain in force unchanged. **Vscode customizations apply is not image build** remains in force unchanged. ## ADDED Requirements @@ -25,9 +25,9 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g **Order relative to open and postAttach** -- On `up` / `clone` / `rebuild` with `--vscode`: run extensions apply (soft-fail) **before** best-effort open, **then** open, **then** postAttach per realized **postAttachCommand policy (CLI-only)** (fail-keep; CLI attach is not gated on open success). -- On `up` / `clone` / `rebuild` without `--vscode`: run extensions apply; MUST NOT open; postAttach follows realized **postAttachCommand policy (CLI-only)**. -- Extensions apply failure MUST NOT by itself skip or fail open or postAttach; postAttach remains as specified in the realized policy. +- On `up` / `clone` / `rebuild` with `--vscode`: run extensions apply (soft-fail) **before** best-effort open, **then** open, **then** postAttach per existing **postAttachCommand policy (CLI-only)** (unchanged fail-keep; still only after open success). +- On `up` / `clone` / `rebuild` without `--vscode`: run extensions apply; MUST NOT open; MUST NOT run postAttach (skip status when postAttach is present, unchanged). +- Extensions apply failure MUST NOT by itself skip or fail open or postAttach; postAttach gating remains solely `--vscode` + open-success + presence as specified today. - Extensions apply MAY complete (and finalize the marker when full apply succeeds) even when `--vscode` is absent or open later soft-fails. **When extensions apply is SKIPPED** @@ -83,7 +83,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - Then the CLI attempts to install missing extension IDs into the remote extensions directory under the resolved remote connection user home - And each successfully installed ID is listed in the guest `extensions.json` registry (not folder-only) - And the CLI MUST NOT invoke a host VS Code open as part of that command -- And postAttach follows realized **postAttachCommand policy (CLI-only)** +- And postAttach MUST NOT execute (skip status when postAttach is present) - And lifecycle success is preserved when extensions apply soft-fails (absent unrelated failures) #### Scenario: extensions install on fresh clone without --vscode @@ -91,16 +91,14 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - When the user runs `clone` **without** `--vscode` - Then the CLI attempts the same guest extensions install after create-path hooks - And soft-fail does not fail `clone` or delete the container/volume solely due to extensions apply -- And the CLI MUST NOT open VS Code solely because extensions were applied -- And postAttach follows realized **postAttachCommand policy (CLI-only)** +- And the CLI MUST NOT open VS Code or run postAttach solely because extensions were applied #### Scenario: extensions install on rebuild without --vscode - Given a successful `rebuild` create-path on the new container, well-formed extension IDs, and no matching guest marker - When the user runs `rebuild` **without** `--vscode` - Then the CLI attempts guest extensions install after create-path hooks on the **new** container - And rebuild still reports success when apply soft-fails -- And the CLI MUST NOT open VS Code solely because extensions were applied -- And postAttach follows realized **postAttachCommand policy (CLI-only)** +- And the CLI MUST NOT open VS Code or run postAttach solely because extensions were applied #### Scenario: extensions still apply on up when --vscode is set - Given a valid config with well-formed extensions, successful create-path, and no matching guest marker @@ -120,8 +118,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - When the user runs `start --vscode` - Then the CLI MUST NOT install those extensions on that invocation - And after start success the CLI still attempts best-effort open -- And postAttach follows realized **postAttachCommand policy (CLI-only)** -- And resume hooks still follow realized **Start managed container** +- And postAttach still runs only on open success per existing policy #### Scenario: up reuse applies pending extensions without --vscode - Given a running managed container whose guest marker hash does not match the normalized customizations from loadable config (e.g. an extension ID added in config without rebuilding) @@ -181,7 +178,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g - When the user runs with `--vscode` and open soft-fails - Then the CLI still attempts extensions install for that invocation (command gate, not open-success gate) - And lifecycle success is unchanged by open soft-fail alone -- And postAttach follows realized **postAttachCommand policy (CLI-only)** (open soft-fail MUST NOT skip CLI-attach postAttach) +- And postAttach remains skipped per existing policy - And the marker MAY be finalized when extensions apply fully succeeds even though open soft-failed #### Scenario: extensions soft-fail keeps lifecycle success @@ -215,7 +212,7 @@ Extensions apply MUST NOT be gated on `--vscode`. Extensions apply MUST NOT be g ### Requirement: VS Code attach acceptance **Domain:** `vscode` -*(Delta — replace apply bullet 4 and the docs scenario. Bullet 3 stays as in the realized spec (CLI attach model). This change MUST NOT re-gate postAttach on `--vscode`.)* +*(Delta — replace apply bullet 4 and the docs scenario; preserve manual attach, optional open, and postAttach hook.)* MVP acceptance for editor integration is: @@ -223,10 +220,9 @@ MVP acceptance for editor integration is: 2. **Optional best-effort open (additive):** When the user passes `--vscode` on `up`, `start`, `clone`, or `rebuild`, the CLI MUST attempt a best-effort open of a new VS Code window on the resolved remote workspace folder per **VS Code best-effort open**. Open failure MUST be soft (warn; lifecycle success preserved **by itself**). Without `--vscode`, no automatic open is required. - 3. **CLI attach hook for postAttach:** Unchanged from the realized spec (CLI attach model; see **postAttachCommand policy (CLI-only)**). - - 4. **CLI apply of config-file vscode customizations:** The CLI MUST apply parseable config-file `customizations.vscode.settings` and `customizations.vscode.extensions` on `up`, `clone`, and `rebuild` (fresh create-path, `up` reuse, and `up` start-stopped) without requiring `--vscode` or a successful editor open, per the apply requirements. `adevcontainer start` MUST NOT apply settings or extensions. `--vscode` MUST NOT be an apply gate; it remains the open flag only. Manual UI attach is not an apply trigger. Apple attach still does not auto-install. Apply failures are soft-fail and MUST NOT be presented as full Dev Containers parity. +3. **CLI attach hook for postAttach:** A successful best-effort open under `--vscode` is the product’s CLI attach hook for gating `postAttachCommand` (see **postAttachCommand policy (CLI-only)**). This is an approximation of IDE attach, not confirmation that the remote session is fully ready. This bullet is unchanged. +4. **CLI apply of config-file vscode customizations:** The CLI MUST apply parseable config-file `customizations.vscode.settings` and `customizations.vscode.extensions` on `up`, `clone`, and `rebuild` (fresh create-path, `up` reuse, and `up` start-stopped) without requiring `--vscode` or a successful editor open, per the apply requirements. `adevcontainer start` MUST NOT apply settings or extensions. `--vscode` MUST NOT be an apply gate; it remains the open + postAttach gate only. Manual UI attach is not an apply trigger. Apple attach still does not auto-install. Apply failures are soft-fail and MUST NOT be presented as full Dev Containers parity. #### Scenario: Running container is attachable target - Given a successful `up` (or `clone`) @@ -245,7 +241,7 @@ MVP acceptance for editor integration is: - Then the text MUST NOT claim that manual UI attach or full Dev Containers extension-driven apply is implemented - And it MUST describe soft-fail - And it MUST describe that settings and extensions apply by default on `up` / `clone` / `rebuild` -- And it MUST describe that `--vscode` gates open, not apply +- And it MUST describe that `--vscode` gates open and postAttach only, not apply - And it MUST describe that `start` does not apply settings or extensions --- @@ -253,28 +249,27 @@ MVP acceptance for editor integration is: ### Requirement: Optional `--vscode` flag on up, start, clone, and rebuild **Domain:** `vscode` -*(Delta — `--vscode` remains open only for apply purposes; drop the rebuild “extensions apply (flag gate only)” clause. postAttach follows the realized CLI attach model. `start` MUST NOT apply customizations.)* +*(Delta — `--vscode` remains open + postAttach only; drop the rebuild “extensions apply (flag gate only)” clause. Remainder of this requirement is unchanged.)* -When `--vscode` is **absent**, those commands MUST NOT invoke a host VS Code open. When `--vscode` is **present**, after the command’s container lifecycle has reached the `waitFor` connection point and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder**. postAttach after that open is specified under realized **postAttachCommand policy (CLI-only)**. +When `--vscode` is **absent**, those commands MUST behave as today for editor open (no automatic editor open). When `--vscode` is **present**, after the command’s container lifecycle succeeds and the managed container is running (or already running for a start no-op), the CLI MUST attempt a **best-effort** open of a **new** VS Code window attached to that container at the **resolved remote workspace folder** (see VS Code best-effort open). postAttach gating after that open is specified under **postAttachCommand policy (CLI-only)**. -`--vscode` MUST NOT gate settings apply or extensions apply. On `up`, `clone`, and `rebuild`, customizations apply (when pending) MUST run whether the flag is present or not, and when the flag is present MUST run **before** the open attempt. On `start`, the flag still requests open (and postAttach per realized policy); `start` MUST NOT apply customizations. On CLI-attach paths, omitting `--vscode` MUST NOT skip postAttach. +`--vscode` MUST NOT gate settings apply or extensions apply. On `up`, `clone`, and `rebuild`, customizations apply (when pending) MUST run whether the flag is present or not, and when the flag is present MUST run **before** the open attempt. On `start`, the flag still requests open + postAttach only; `start` MUST NOT apply customizations. -On `rebuild`, `--vscode` behavior MUST be identical to the `up`/`clone` create path for **open**: after rebuild lifecycle reaches `waitFor` on the new container, customizations apply (not flag-gated) has already run or runs before open; then attempt a best-effort open. postAttach follows realized **postAttachCommand policy (CLI-only)** — never failing rebuild solely due to open. +On `rebuild`, `--vscode` behavior MUST be identical to the `up`/`clone` create path for **open and postAttach only**: after rebuild lifecycle success on the new container, customizations apply (not flag-gated) has already run or runs before open; then attempt a best-effort open; on open **success**, run the postAttach gate; on open **soft-fail**, skip postAttach with status when present — never failing rebuild solely due to open. -#### Scenario: --vscode still only gates open not apply on up +#### Scenario: --vscode still only gates open and postAttach on up - Given a successful `up` create-path with well-formed settings and extensions and a config that also has `postAttachCommand` - When the user runs `up` **without** `--vscode` - Then settings and extensions apply still run per the apply requirements - And the CLI MUST NOT invoke a host VS Code open -- And postAttach MUST execute as CLI attach +- And postAttach MUST NOT execute (skip status when present) #### Scenario: --vscode on start still opens without applying customizations - Given a managed container that `start` can select and a config with settings, extensions, and `postAttachCommand` - When the user runs `start --vscode` and host `code` launch succeeds - Then after start success the CLI attempts to open a new VS Code window attached to that container -- And postAttach follows realized **postAttachCommand policy (CLI-only)** +- And postAttach runs after that successful open - And the CLI MUST NOT apply settings or extensions on that `start` invocation -- And resume hooks still follow realized **Start managed container** #### Scenario: without --vscode behavior unchanged for open - Given any valid `up`, `start`, `clone`, or `rebuild` invocation @@ -368,8 +363,7 @@ When resolved config retains a non-empty well-formed `customizations.vscode.sett - Given well-formed settings, a managed container that `start` can select, and a guest marker missing or drifted - When the user runs `start --vscode` - Then the CLI MUST NOT merge or repair Machine settings on that invocation -- And open / postAttach still follow realized `--vscode` and **postAttachCommand policy (CLI-only)** -- And resume hooks still follow realized **Start managed container** +- And open / postAttach still follow existing `--vscode` policy --- @@ -460,7 +454,7 @@ The CLI MUST record successful application of the **normalized** customizations ### Requirement: Up lifecycle (create, start, reuse) **Domain:** `core` -*(Delta — replace the vscode customizations apply matrix and the following paragraph. Hook and postAttach matrix rows stay as in the realized spec (CLI attach / start hooks). This change MUST NOT restore “Bind start-stopped postStartCommand remains an `up` path only”.)* +*(Delta — replace the vscode customizations apply matrix and the following paragraph; hook and postAttach matrix rows unchanged.)* | Path | Vscode customizations apply | |------|-----------------------------| @@ -470,10 +464,10 @@ The CLI MUST record successful application of the **normalized** customizations | `up` start-stopped (matching hash) with loadable config and marker pending/drift | after `postStartCommand` when that hook runs: settings repair and extensions install as applicable (soft-fail); **not** gated on `--vscode` | | `adevcontainer start` (any flag combination) | **no** settings or extensions apply | | Any path with matching marker for full normalized payload | skip redundant settings+extensions apply (`start` still does not apply) | -| `up`/`clone`/`rebuild` with `--vscode` | apply first (if pending), then open; postAttach follows realized **postAttachCommand policy (CLI-only)** | -| `start` with `--vscode` | no apply; then open; postAttach follows realized **postAttachCommand policy (CLI-only)** | +| `up`/`clone`/`rebuild` with `--vscode` | apply first (if pending), then open, then postAttach only on open success per existing matrix | +| `start` with `--vscode` | no apply; then open; then postAttach only on open success per existing matrix | -postAttach matrix rows and gating text remain as in the realized spec. Customizations apply is **not** part of create-path delete-on-fail, **not** folded into postAttach execution, and **not** run on `start`. +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. Bind start-stopped `postStartCommand` remains an `up` path only. #### Scenario: up reuse still applies customizations - Given a matching running container and a drifted guest customizations marker @@ -491,18 +485,28 @@ postAttach matrix rows and gating text remain as in the realized spec. Customiza ### Requirement: Start managed container **Domain:** `managed-lifecycle` -*(Delta — apply exclusion only. Resume hooks follow realized **Start managed container** / **Lifecycle hook surface**. This change does **not** lock `start` as runtime-only and MUST NOT remove postStart from bare `start`.)* +*(Delta — additive apply exclusion; selection, runtime start/no-op, no re-clone, and locked hook split are unchanged. MUST NOT add `postStartCommand` to bare `start`.)* + +**Runtime behavior** remains: start a stopped managed container; already-running is success no-op; MUST NOT re-clone; MUST NOT run the full `up` or `clone` create path. + +**Lifecycle hooks on start (locked split)** remain: volume-mode and bind-mode bare `adevcontainer start` are **runtime start only** — MUST NOT run lifecycle hooks (`postStartCommand` included). Bind start-stopped `postStartCommand` remains via `up` only. **Vscode customizations on start** - `adevcontainer start` MUST NOT apply `customizations.vscode.settings` or `customizations.vscode.extensions`, with or without `--vscode`. -- Config load on `start` MAY be used for hooks, open, and postAttach. It MUST NOT be used to apply settings or extensions. +- When `--vscode` is set, `start` MAY still load config from labels for **postAttach** only (load errors → treat postAttach absent; MUST NOT fail start solely for that load) and MUST still follow **VS Code best-effort open** and **postAttachCommand policy (CLI-only)**. +- Config load on `start` MUST NOT be used to apply settings or extensions. + +#### Scenario: Volume-mode start runs no hooks +- Given a volume-mode managed container with labels from clone and a config that had `postStartCommand` at create time +- When the user runs `adevcontainer start --name ` on a stopped container +- Then the container starts and **no** lifecycle hooks are executed on this path #### Scenario: start does not apply vscode customizations - Given a managed container whose config has well-formed settings and extensions and whose guest marker is missing or drifted - When the user runs `adevcontainer start` without or with `--vscode` - Then the CLI MUST NOT apply those settings or extensions on this path -- And resume hooks still follow the realized Start managed container requirement +- And MUST NOT run `postStartCommand` on this path ## REMOVED Requirements diff --git a/specs/changes/vscode-customizations-up-clone-rebuild/tasks.md b/specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/tasks.md similarity index 100% rename from specs/changes/vscode-customizations-up-clone-rebuild/tasks.md rename to specs/changes/archive/20260814-vscode-customizations-up-clone-rebuild/tasks.md