diff --git a/README.md b/README.md index 1da33fa..111b763 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Apple Dev Container CLI (adevcontainer) [![CI](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml/badge.svg)](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml) -[![tests](https://img.shields.io/badge/tests-1163%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) +[![tests](https://img.shields.io/badge/tests-1176%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Native Swift CLI that reads `devcontainer.json` and runs development environments on Apple [`container`](https://github.com/apple/container). @@ -103,7 +103,7 @@ Apple upgrades wipe the plugin directory. PATH `adevcontainer` survives. Restage ### Interactive picker -The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` commands open an interactive picker (picklist) in an interactive TTY when `--name` is not provided and multiple managed containers exist. In a non-interactive terminal, `--name ` is required in this case. +The `start`, `refresh-credentials`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` commands open an interactive picker (picklist) in an interactive TTY when `--name` is not provided and multiple managed containers exist. In a non-interactive terminal, `--name ` is required in this case. | Command | Purpose | | --- | --- | @@ -114,7 +114,8 @@ The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` command | `adevcontainer clone [--vscode] [--resume ]` | Clone a repository into a named volume and start its dev container | | `adevcontainer exec [-it] [--name ] [--] [cmd…]` | Open a shell or run a command in a running managed container | | `adevcontainer list [--json]` | List managed dev containers | -| `adevcontainer start [--name ] [--vscode] [--json]` | Start a container by name or with the interactive picker | +| `adevcontainer start [--name ] [--vscode] [--json]` | Start a container by name or with the interactive picker. A real start refreshes git credentials; already running does not | +| `adevcontainer refresh-credentials [--name ]` | Refresh git credentials in a running managed container from the host | | `adevcontainer stop [--name ]` | Stop a container by name or with the interactive picker | | `adevcontainer inspect [--name ]` | Inspect a container by name or with the interactive picker | | `adevcontainer delete [--name ]` | Remove a container, keeping its managed volumes | diff --git a/Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift b/Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift new file mode 100644 index 0000000..6101dec --- /dev/null +++ b/Sources/ADevContainerLib/Commands/RefreshCredentialsCommand.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum RefreshCredentialsCommand { + /// Refresh git credentials in an already-running managed container from host + /// `git credential fill`. Does not start, stop, fetch, pull, push, or write the + /// workspace. Credential material enters the guest via stdin only. + public static func run( + name: String? = nil, + runtime: AppleContainerRuntime, + picker: InteractivePicker = .default, + credentials: any GitCredentialProviding = HostGitCredential() + ) throws { + let info = try ManagedContainers.resolveSelection( + name: name, + runtime: runtime, + picker: picker + ) + guard info.isRunning else { + throw CLIError( + code: CLIErrorCode.containerNotRunning, + message: "Container \(info.name) is not running (state: \(info.state))", + hint: "Run '\(CommandSurface.commandPrefix) start --name \(info.name)' to start it and refresh credentials" + ) + } + + StatusPrinter.status("Refreshing git credentials", item: info.name) + let refreshed: Bool + do { + refreshed = try GuestGitCredentialSeed( + credentials: credentials, + runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() + ).seedFromLabels( + containerId: info.id, + labels: info.labels, + connectionUser: GuestGitCredentialSeed.connectionUser(from: info.labels), + runtime: runtime + ) + } catch let error as CLIError { + throw CLIError( + code: error.code, + message: "Failed to refresh git credentials in the container (\(error.message))", + hint: error.hint + ) + } + if refreshed { + StatusPrinter.status("Git credentials refreshed", item: info.name) + } else { + StatusPrinter.status("No host git credentials to refresh", item: info.name) + } + } +} diff --git a/Sources/ADevContainerLib/Commands/StartCommand.swift b/Sources/ADevContainerLib/Commands/StartCommand.swift index 734266b..5266a4e 100644 --- a/Sources/ADevContainerLib/Commands/StartCommand.swift +++ b/Sources/ADevContainerLib/Commands/StartCommand.swift @@ -31,7 +31,8 @@ public enum StartCommand { picker: InteractivePicker = .default, isTTY: Bool = AppleContainerConfig.stdinIsTTY(), openEditorPrompt: RecoveryOpenEditorPrompt = .default, - localEnv: [String: String] = ProcessInfo.processInfo.environment + localEnv: [String: String] = ProcessInfo.processInfo.environment, + credentials: any GitCredentialProviding = HostGitCredential() ) throws { let info = try ManagedContainers.resolveSelection( name: options.name, @@ -82,6 +83,15 @@ public enum StartCommand { ) return } + // Stopped → running only. Already-running returned above. Soft-fail: a bad + // refresh must not fail start, delete the container, or enter recovery. + GuestGitCredentialSeed.refreshSoft( + containerId: info.id, + labels: info.labels, + connectionUser: GuestGitCredentialSeed.connectionUser(from: info.labels), + runtime: runtime, + credentials: credentials + ) // Remelt after start for postStart/postAttach. Never run initialize here: // volume config becoming readable must not violate initialize-before-start. if hooksConfig == nil { diff --git a/Sources/ADevContainerLib/Commands/UpCommand.swift b/Sources/ADevContainerLib/Commands/UpCommand.swift index 9645a64..bf40594 100644 --- a/Sources/ADevContainerLib/Commands/UpCommand.swift +++ b/Sources/ADevContainerLib/Commands/UpCommand.swift @@ -256,6 +256,15 @@ public enum UpCommand { resetExistingName: existing.name ) } + // Stopped → running only. Reuse of an already-running container returned + // above. Soft-fail: do not fail up, delete the container, or recover. + GuestGitCredentialSeed.refreshSoft( + containerId: existing.id, + labels: existing.labels, + connectionUser: GuestGitCredentialSeed.connectionUser(from: existing.labels), + runtime: runtime, + credentials: credentials + ) var reuseConfig = configForReuse(resolved.config, labels: existing.labels) PostAttachConfigLoader.mergeFeaturePostAttach( into: &reuseConfig, diff --git a/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift b/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift index 110ec1f..87b8573 100644 --- a/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift +++ b/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift @@ -11,10 +11,13 @@ import Foundation /// In-container failures throw (callers soft-fail with a warning). /// /// The guest helper matches protocol+host and ignores path, so a later `git fetch` -/// still hits the imported credential when the query includes a path. -/// Non-Azure hosts keep the previous append-only global helper. `dev.azure.com` -/// only gets a URL-scoped helper override and `useHttpPath`, so other hosts are -/// unchanged and an inherited GCM cannot abort that host. +/// still hits the imported credential when the query includes a path. An expired +/// JWT is a miss and is removed so it is not replayed. `erase` removes the matching +/// protocol+host entries. +/// Non-Azure hosts keep the append-only global helper. `dev.azure.com` gets a +/// URL-scoped helper (empty values removed, product helper exactly once) and +/// `useHttpPath`, so other hosts are unchanged and an inherited GCM cannot abort +/// that host. Host `credential.helper` is never copied into the guest. public struct GuestGitCredentialSeed { public var credentials: any GitCredentialProviding public var runner: any ProcessRunning @@ -33,13 +36,15 @@ public struct GuestGitCredentialSeed { /// Seed the store in `containerId` as `connectionUser`. `hostWorkspace` and `gitURL` /// select bind vs volume discovery (exactly one is provided by callers). + /// Returns false on a silent skip (no URL, no fill); throws only on exec failure. + @discardableResult public func seed( containerId: String, hostWorkspace: String?, gitURL: String?, connectionUser: String?, runtime: AppleContainerRuntime - ) throws { + ) throws -> Bool { let urls: [String] let workspace = hostWorkspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let stamped = gitURL?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -48,7 +53,7 @@ public struct GuestGitCredentialSeed { } else if !stamped.isEmpty { urls = [stamped] } else { - return + return false } var entries: [Entry] = [] @@ -81,12 +86,87 @@ public struct GuestGitCredentialSeed { entries.append(entry) } } + guard !entries.isEmpty else { return false } try install( containerId: containerId, entries: entries, connectionUser: connectionUser, runtime: runtime ) + return true + } + + /// Bind uses the host workspace; volume uses the stamped git URL. + /// A `volume://` local folder is not a host path. + public static func credentialSources( + from labels: [String: String] + ) -> (hostWorkspace: String?, gitURL: String?) { + let mode = labels[ContainerIdentity.labelWorkspaceMode]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let gitURL = nonEmpty(labels[ContainerIdentity.labelGitURL]) + if mode == ContainerIdentity.workspaceModeVolume { + return (nil, gitURL) + } + let folder = labels[ContainerIdentity.labelLocalFolder]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if folder.isEmpty || folder.hasPrefix("volume://") { + return (nil, gitURL) + } + return (folder, nil) + } + + public static func connectionUser(from labels: [String: String]) -> String? { + nonEmpty(labels[ContainerIdentity.labelRemoteUser]) + } + + /// Re-seed a running container. Does not start, stop, fetch, pull, or push. + @discardableResult + public func seedFromLabels( + containerId: String, + labels: [String: String], + connectionUser: String?, + runtime: AppleContainerRuntime + ) throws -> Bool { + let sources = Self.credentialSources(from: labels) + return try seed( + containerId: containerId, + hostWorkspace: sources.hostWorkspace, + gitURL: sources.gitURL, + connectionUser: connectionUser, + runtime: runtime + ) + } + + /// Soft-fail refresh for a real start and `up` start-stopped. + /// Never deletes the container, fails the caller, or enters recovery. + public static func refreshSoft( + containerId: String, + labels: [String: String], + connectionUser: String?, + runtime: AppleContainerRuntime, + credentials: any GitCredentialProviding + ) { + do { + try GuestGitCredentialSeed( + credentials: credentials, + runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner() + ).seedFromLabels( + containerId: containerId, + labels: labels, + connectionUser: connectionUser, + runtime: runtime + ) + } catch { + let detail = (error as? CLIError)?.message ?? error.localizedDescription + StatusPrinter.warning( + "Git credential refresh failed; continuing without forwarded credentials: \(detail)" + ) + } + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed } /// Install the guest helper and approve one credential clone already filled on the host. @@ -145,15 +225,153 @@ public struct GuestGitCredentialSeed { return result } + /// POSIX-sh helpers injected ahead of the action `case`. Expired-JWT removal and + /// erase share one atomic rewrite. Decode failures stay silent and are not a miss. + static func helperSupportScript() -> String { + #""" +drop_blocks() { + mode="$1" + dproto="$2" + dhost="$3" + duser="${4-}" + [ -f "$STORE" ] || return 0 + mkdir -p "$DIR" + tmp="$STORE.tmp" + : > "$tmp" + chmod 600 "$tmp" + dp="" + dh="" + du="" + dpw="" + flush_drop() { + [ -n "$dp" ] || [ -n "$dh" ] || return 0 + drop=0 + if [ "$dp" = "$dproto" ] && [ "$dh" = "$dhost" ]; then + if [ "$mode" = "host" ] || [ "$du" = "$duser" ]; then + drop=1 + fi + fi + if [ "$drop" -eq 0 ]; then + printf 'protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n' "$dp" "$dh" "$du" "$dpw" >> "$tmp" + fi + dp="" + dh="" + du="" + dpw="" + } + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "") + flush_drop + ;; + *=*) + key=${line%%=*} + val=${line#*=} + case "$key" in + protocol) dp="$val" ;; + host) dh="$val" ;; + username) du="$val" ;; + password) dpw="$val" ;; + esac + ;; + esac + done < "$STORE" + flush_drop + chmod 600 "$tmp" + mv "$tmp" "$STORE" +} +jwt_expired() { + pw=$1 + case "$pw" in + *.*.*) ;; + *) return 1 ;; + esac + seg1=${pw%%.*} + rest=${pw#*.} + seg2=${rest%%.*} + seg3=${rest#*.} + [ -n "$seg1" ] && [ -n "$seg2" ] && [ -n "$seg3" ] || return 1 + case "$seg3" in + *.*) return 1 ;; + esac + payload=$(printf '%s' "$seg2" | LC_ALL=C tr '_-' '/+' 2>/dev/null) || return 1 + case $(( ${#payload} % 4 )) in + 2) payload="${payload}==" ;; + 3) payload="${payload}=" ;; + 1) return 1 ;; + esac + json="" + if command -v base64 >/dev/null 2>&1; then + if probe=$(printf 'YQ==' | base64 -d 2>/dev/null) && [ "$probe" = "a" ]; then + json=$(printf '%s' "$payload" | base64 -d 2>/dev/null) || json="" + elif probe=$(printf 'YQ==' | base64 -D 2>/dev/null) && [ "$probe" = "a" ]; then + json=$(printf '%s' "$payload" | base64 -D 2>/dev/null) || json="" + fi + fi + if [ -z "$json" ] && command -v openssl >/dev/null 2>&1; then + if probe=$(printf 'YQ==' | openssl base64 -d -A 2>/dev/null) && [ "$probe" = "a" ]; then + json=$(printf '%s' "$payload" | openssl base64 -d -A 2>/dev/null) || json="" + fi + fi + [ -n "$json" ] || return 1 + rest=$(printf '%s' "$json" | LC_ALL=C tr -d '\n' 2>/dev/null) || return 1 + exp="" + while :; do + case "$rest" in + *'"exp"'*) rest=${rest#*'"exp"'} ;; + *) break ;; + esac + while :; do + case "$rest" in + [[:space:]]*) rest=${rest#?} ;; + *) break ;; + esac + done + case "$rest" in + ':'*) rest=${rest#:} ;; + *) continue ;; + esac + while :; do + case "$rest" in + [[:space:]]*) rest=${rest#?} ;; + *) break ;; + esac + done + exp="" + while :; do + case "$rest" in + [0-9]*) + exp="${exp}${rest%"${rest#?}"}" + rest=${rest#?} + ;; + *) break ;; + esac + done + [ -n "$exp" ] && break + done + [ -n "$exp" ] || return 1 + now=$(date +%s 2>/dev/null) || return 1 + case "$now" in + ''|*[!0-9]*) return 1 ;; + esac + if [ "$exp" -le "$((now + 60))" ]; then + return 0 + fi + return 1 +} +"""# + } + /// The in-container POSIX-sh credential helper (get/store/erase) that the seed - /// script writes to `$HOME/.adevcontainer/git-credential-adev` (mode 0700) and - /// configures via `credential.helper` after an empty reset. `get` matches the - /// persisted store by (protocol, host), ignoring the queried username and path - /// so `credential.useHttpPath` queries still hit the imported credential; `store` - /// persists to `$HOME/.adevcontainer/git-credentials` (mode 0600) deduped by - /// (protocol, host, username); `erase` is a no-op. Store format: one - /// protocol/host/username/password key block per entry, blank-line terminated - /// (git's own credential protocol shape), so raw values round-trip losslessly. + /// script writes to `$HOME/.adevcontainer/git-credential-adev` (mode 0700). + /// `get` matches the persisted store by (protocol, host), ignoring the queried + /// username and path so `credential.useHttpPath` queries still hit the imported + /// credential. A JWT whose `exp` is within 60 seconds is a miss and that store + /// entry is removed; non-JWTs and undecodable tokens are returned as stored. + /// `store` persists to `$HOME/.adevcontainer/git-credentials` (mode 0600) deduped + /// by (protocol, host, username). `erase` removes protocol+host entries with an + /// atomic rewrite (mode 0600). Store format: one protocol/host/username/password + /// key block per entry, blank-line terminated, so raw values round-trip. static func helperScript() -> String { [ "#!/bin/sh", @@ -187,6 +405,10 @@ public struct GuestGitCredentialSeed { " su=\"\"", " spw=\"\"", " emit() {", + " if jwt_expired \"$spw\" 2>/dev/null; then", + " drop_blocks user \"$sp\" \"$sh\" \"$su\"", + " exit 0", + " fi", " if [ -n \"$quser\" ]; then", " outuser=\"$quser\"", " else", @@ -276,18 +498,26 @@ public struct GuestGitCredentialSeed { " exit 0", " ;;", "erase)", + " [ -n \"$qproto\" ] && [ -n \"$qhost\" ] || exit 0", + " [ -f \"$STORE\" ] || exit 0", + " drop_blocks host \"$qproto\" \"$qhost\"", " exit 0", " ;;", "esac", "exit 0" ].joined(separator: "\n") + .replacingOccurrences( + of: "done\ncase \"${1:-}\" in", + with: "done\n\(Self.helperSupportScript())\ncase \"${1:-}\" in" + ) } /// One in-container script: write the username-agnostic credential helper, then - /// approve stdin blocks. `appendGlobalHelper` is the pre-Azure up/rebuild path - /// (`git config --global --add`). `configureAzureHost` adds only the - /// `dev.azure.com` URL-scoped helper override and `useHttpPath`. Never resets - /// the global helper list. Entries travel via stdin — never argv or env. + /// approve stdin blocks. `appendGlobalHelper` is the non-Azure path + /// (`git config --global --add`). `configureAzureHost` ensures the product helper + /// is the only empty-free `dev.azure.com` URL-scoped helper and sets `useHttpPath`. + /// Never writes an empty URL-scoped helper and never resets the global helper list. + /// Entries travel via stdin — never argv or env. static func seedScript(appendGlobalHelper: Bool = true, configureAzureHost: Bool = false) -> String { var lines = [ "set -e", @@ -329,10 +559,32 @@ public struct GuestGitCredentialSeed { lines.append("git config --global --add credential.helper \"$HELPER\" >/dev/null 2>&1") } if configureAzureHost { - // Empty URL-scoped helper clears inherited helpers for dev.azure.com only. + // Drop an empty URL-scoped helper (it resets the chain) and keep every + // other value. The product helper is present exactly once. Do not reset + // the global credential.helper list. lines += [ - "git config --global --replace-all credential.https://dev.azure.com.helper \"\" >/dev/null 2>&1", - "git config --global --add credential.https://dev.azure.com.helper \"$HELPER\" >/dev/null 2>&1", + "azure_key=\"credential.https://dev.azure.com.helper\"", + "azure_list=\"$HOME/.adevcontainer/.azure-helper-values\"", + ": > \"$azure_list\"", + "chmod 600 \"$azure_list\"", + "git config --global --get-all \"$azure_key\" > \"$azure_list\" 2>/dev/null || true", + "git config --global --unset-all \"$azure_key\" >/dev/null 2>&1 || true", + "azure_seen=0", + "while IFS= read -r azure_value || [ -n \"$azure_value\" ]; do", + " [ -n \"$azure_value\" ] || continue", + " if [ \"$azure_value\" = \"$HELPER\" ]; then", + " if [ \"$azure_seen\" -eq 0 ]; then", + " git config --global --add \"$azure_key\" \"$azure_value\" >/dev/null 2>&1", + " azure_seen=1", + " fi", + " continue", + " fi", + " git config --global --add \"$azure_key\" \"$azure_value\" >/dev/null 2>&1", + "done < \"$azure_list\"", + "rm -f \"$azure_list\"", + "if [ \"$azure_seen\" -eq 0 ]; then", + " git config --global --add \"$azure_key\" \"$HELPER\" >/dev/null 2>&1", + "fi", "git config --global credential.https://dev.azure.com.useHttpPath true >/dev/null 2>&1", ] } diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index 086f2b7..19c3376 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -338,7 +338,7 @@ public enum CommandSurface { CLIError( code: CLIErrorCode.usage, message: "Unknown subcommand '\(subcommand)'", - hint: "Try: doctor | plugin | up | clone | rebuild | list | start | exec | stop | delete | purge | inspect" + hint: "Try: doctor | plugin | up | clone | rebuild | list | start | refresh-credentials | exec | stop | delete | purge | inspect" ) } @@ -359,7 +359,9 @@ 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 container (initialize + postStart on real start; --json suppresses recovery prompt) + start [--name] Start a stopped managed container (initialize + postStart on real start; --json suppresses recovery prompt). A real start refreshes git credentials; already running does not + refresh-credentials [--name] + Refresh git credentials in a running managed container from the host (does not start, stop, fetch, pull, or push) 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) @@ -370,7 +372,7 @@ public enum CommandSurface { Options: -w, --workspace Workspace root for `up` only (default: cwd) - --name Managed container name/id (exec/start/stop/delete/purge/rebuild/inspect) + --name Managed container name/id (exec/start/refresh-credentials/stop/delete/purge/rebuild/inspect) --resume Resume clone from a retained config checkout (clone only) --reuse-volume Keep an existing clone workspace volume (clone only) --replace-volume Delete an existing clone workspace volume and clone fresh (clone only) @@ -509,6 +511,10 @@ public enum CommandSurface { 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). + Starting a stopped matching container refreshes git credentials from the host + before postStart (soft-fail; does not fail up or enter recovery). Reusing an + already-running container does not refresh. Use refresh-credentials for a + container that is already running. 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. @@ -578,8 +584,11 @@ public enum CommandSurface { \(cmd) start [--name ] [--vscode] [--json] 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. + (when a host workspace exists), refreshes git credentials from the host + (soft-fail; does not fail start or enter recovery), then config postStartCommand + and remelted feature postStart. Already running does not refresh credentials + and is a success no-op for those hooks. Use refresh-credentials to refresh a + container that is already running. --vscode: best-effort open VS Code on the labeled remote workspace folder after start (inspect for id/image/folder). Soft-fail open. postAttach runs after a @@ -590,6 +599,24 @@ public enum CommandSurface { and --json fail with that exact rebuild hint. Start never opens an editor or retries start. Not full extension parity. """ + case "refresh-credentials": + return """ + \(cmd) refresh-credentials [--name ] + + Refresh git credentials in a running managed container from the host git + credential store. Bind mode reads the host workspace's HTTPS fetch remotes; + volume mode uses the stamped git URL. Selection matches start (--name or + the interactive picker). + + The container must already be running. If it is stopped, this command fails + and does not start it — run `\(cmd) start --name ` (start also refreshes + credentials). Already-running start and up reuse do not refresh. + + Does not git fetch, pull, or push, and does not write the workspace. + Credential material is read on the host and passed to the guest on stdin only, + never in argv or the environment. An exec failure fails this command. + dev.azure.com keeps useHttpPath and does not install an empty helper reset. + """ case "exec": return """ \(cmd) exec [-it] [--name ] [--] [cmd...] diff --git a/Sources/adevcontainer/AdevcontainerMain.swift b/Sources/adevcontainer/AdevcontainerMain.swift index b0e95ee..0bb3169 100644 --- a/Sources/adevcontainer/AdevcontainerMain.swift +++ b/Sources/adevcontainer/AdevcontainerMain.swift @@ -153,6 +153,20 @@ struct AdevcontainerMain { ) return 0 + case "refresh-credentials": + guard parsed.passthrough.isEmpty else { + throw CLIError( + code: CLIErrorCode.usage, + message: "refresh-credentials does not accept positional arguments", + hint: "Usage: \(CommandSurface.commandPrefix) refresh-credentials [--name ]" + ) + } + try RefreshCredentialsCommand.run( + name: parsed.name, + runtime: runtime + ) + return 0 + case "exec": let command = parsed.passthrough let interactive = diff --git a/Tests/adevcontainerTests/AllCommandTests.swift b/Tests/adevcontainerTests/AllCommandTests.swift index dbfcbad..f54b008 100644 --- a/Tests/adevcontainerTests/AllCommandTests.swift +++ b/Tests/adevcontainerTests/AllCommandTests.swift @@ -974,7 +974,21 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ } ] let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "p")) + creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "reuse-secret")) + let host = RecordingHostProcessRunner() + let folder = resolved.labels[ContainerIdentity.labelLocalFolder] ?? workspace.path + host.handler = { call in + if call.arguments == ["-C", folder, "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) let result = try UpCommand.run( options: UpOptions(workspacePath: workspace.path, skipPull: true), @@ -984,13 +998,16 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ ) try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expect(creds.fillCalls.isEmpty, "reuse must not fill") + try MiniTest.expect(host.calls.isEmpty, "reuse must not ask host git") try MiniTest.expect( - !mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("credential.helper") == true }, + !mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true }, "reuse must not run a seeding exec" ) }), - ("upStartStoppedSeedsNothing", { - let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) + ("upStartStoppedRefreshesCredentials", { + let workspace = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20", "remoteUser": "vscode", "postStartCommand": "echo postStart" } + """) defer { try? FileManager.default.removeItem(at: workspace) } let mock = MockProcessRunner() let resolved = try ConfigResolver.resolve(workspacePath: workspace.path, localEnv: [:]) @@ -1005,27 +1022,526 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [ let data = try! JSONSerialization.data(withJSONObject: [entry]) return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) } - if args.first == "start" { + if args.first == "start" || args.first == "exec" || args.first == "inspect" { return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } return nil } ] + let folder = resolved.labels[ContainerIdentity.labelLocalFolder] ?? workspace.path + let password = "refresh-secret-token" + let host = RecordingHostProcessRunner() + host.handler = { call in + if call.arguments == ["-C", folder, "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } let creds = SeedMockCredential() - creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "p")) + creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( + GitHTTPSCredentials(username: "plantsuite", password: password) + ) let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) let result = try UpCommand.run( options: UpOptions(workspacePath: workspace.path, skipPull: true), runtime: runtime, localEnv: [:], + isTTY: false, credentials: creds ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expect(creds.fillCalls.isEmpty, "start-stopped must not fill") - try MiniTest.expect( - !mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("credential.helper") == true }, - "start-stopped must not run a seeding exec" + try MiniTest.expectEqual(creds.fillCalls, ["https://dev.azure.com/org/proj/_git/repo"]) + let seeds = mock.calls.filter { + $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true + } + try MiniTest.expectEqual(seeds.count, 1, "start-stopped refreshes once") + let script = seeds[0].arguments.last ?? "" + try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) + try MiniTest.expect(!script.contains("credential.https://dev.azure.com.helper \"\"")) + try MiniTest.expect(!script.contains(password)) + try MiniTest.expect(seeds[0].environment?.values.contains(password) != true) + try MiniTest.expectEqual( + String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", + "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + let calls = mock.calls.map(\.arguments) + let seedIdx = calls.firstIndex { $0.first == "exec" && $0.last?.contains("git-credential-adev") == true } + let hookIdx = calls.firstIndex { $0.last?.contains("echo postStart") == true } + try MiniTest.expect(seedIdx != nil && hookIdx != nil && seedIdx! < hookIdx!, "refresh before postStart") + }), + ("upStartStoppedRefreshSoftFails", { + let workspace = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20", "postStartCommand": "echo postStart" }"#) + defer { try? FileManager.default.removeItem(at: workspace) } + let mock = MockProcessRunner() + let resolved = try ConfigResolver.resolve(workspacePath: workspace.path, localEnv: [:]) + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, + state: "stopped", + labels: resolved.labels + ) + 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", args.last?.contains("git-credential-adev") == true { + return ProcessResult( + exitCode: 1, + stdout: Data(), + stderr: Data("password=super-secret-leak https://user:tok@dev.azure.com/x\n".utf8) + ) + } + if args.first == "start" || args.first == "exec" || args.first == "inspect" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let folder = resolved.labels[ContainerIdentity.labelLocalFolder] ?? workspace.path + let host = RecordingHostProcessRunner() + host.handler = { call in + if call.arguments == ["-C", folder, "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://github.com/org/repo.git (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + let creds = SeedMockCredential() + creds.results["https://github.com/org/repo.git"] = .success( + GitHTTPSCredentials(username: "u", password: "super-secret-leak") + ) + var warnings: [String] = [] + let prev = StatusPrinter.onWarning + StatusPrinter.onWarning = { warnings.append($0) } + defer { StatusPrinter.onWarning = prev } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try UpCommand.run( + options: UpOptions(workspacePath: workspace.path, skipPull: true), + runtime: runtime, + localEnv: [:], + isTTY: false, + credentials: creds + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expect(!warnings.isEmpty, "soft-fail warns") + try MiniTest.expect(!warnings.joined().contains("super-secret-leak")) + try MiniTest.expect(!warnings.joined().contains("user:tok")) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }, "soft-fail must not delete") + try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("echo postStart") == true }) + }) +] + +nonisolated(unsafe) let credentialRefreshCommandTests: [(String, () throws -> Void)] = [ + ("startStoppedRefreshesCredentials", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20", "remoteUser": "vscode", "postStartCommand": "echo start-postStart" } + """) + 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.labelRemoteUser] = "vscode" + 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"]) || args.first == "inspect" { + let payload: Any = args.first == "list" ? [entry] as Any : entry as Any + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" || args.first == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let folder = labels[ContainerIdentity.labelLocalFolder] ?? ws.path + let password = "start-refresh-secret" + let host = RecordingHostProcessRunner() + host.handler = { call in + if call.arguments == ["-C", folder, "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + let creds = SeedMockCredential() + creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( + GitHTTPSCredentials(username: "plantsuite", password: password) + ) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName), + runtime: runtime, + isTTY: false, + credentials: creds + ) + try MiniTest.expect(mock.calls.contains { $0.arguments.first == "start" }) + let seeds = mock.calls.filter { + $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true + } + try MiniTest.expectEqual(seeds.count, 1) + try MiniTest.expect(seeds[0].arguments.contains("-u") && seeds[0].arguments.contains("vscode")) + try MiniTest.expect(!seeds[0].arguments.contains { $0.contains(password) }) + try MiniTest.expect(seeds[0].environment?.values.contains(password) != true) + try MiniTest.expectEqual( + String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", + "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" + ) + try MiniTest.expect(!(seeds[0].arguments.last ?? "").contains("helper \"\"")) + let calls = mock.calls.map(\.arguments) + let seedIdx = calls.firstIndex { $0.last?.contains("git-credential-adev") == true } + let hookIdx = calls.firstIndex { $0.last?.contains("echo start-postStart") == true } + try MiniTest.expect(seedIdx != nil && hookIdx != nil && seedIdx! < hookIdx!) + try MiniTest.expect(!host.calls.contains { $0.arguments.contains("fetch") || $0.arguments.contains("pull") || $0.arguments.contains("push") }) + }), + ("startAlreadyRunningDoesNotRefreshCredentials", { + 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: [:]) + var labels = resolved.labels + labels[ContainerIdentity.labelManaged] = ContainerIdentity.managedValue + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "running", labels: labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) || args.first == "inspect" { + let payload: Any = args.first == "list" ? [entry] as Any : entry as Any + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let host = RecordingHostProcessRunner() + host.handler = { _ in + ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://github.com/org/repo.git (fetch)\n".utf8), + stderr: Data() + ) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + let creds = SeedMockCredential() + creds.defaultResult = .success(GitHTTPSCredentials(username: "u", password: "should-not-fill")) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName), + runtime: runtime, + isTTY: false, + credentials: creds + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(creds.fillCalls.isEmpty) + try MiniTest.expect(host.calls.isEmpty) + try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true }) + }), + ("startRefreshSoftFailsAndContinues", { + let ws = try TestRepo.makeTempWorkspace(configJSON: """ + { "image": "alpine:3.20", "postStartCommand": "echo start-postStart" } + """) + 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 + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, state: "stopped", labels: labels + ) + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) || args.first == "inspect" { + let payload: Any = args.first == "list" ? [entry] as Any : entry as Any + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "exec", args.last?.contains("git-credential-adev") == true { + return ProcessResult( + exitCode: 1, + stdout: Data(), + stderr: Data("password=super-secret-leak https://user:tok@example.com/x\n".utf8) + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let folder = labels[ContainerIdentity.labelLocalFolder] ?? ws.path + let host = RecordingHostProcessRunner() + host.handler = { call in + if call.arguments == ["-C", folder, "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://github.com/org/repo.git (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + var rebuilt = false + StartCommand.rebuildOverride = { _ in + rebuilt = true + return RebuildResult( + outcome: "success", + containerId: resolved.containerName, + remoteUser: "root", + remoteWorkspaceFolder: "/workspaces/app", + containerName: resolved.containerName + ) + } + defer { StartCommand.rebuildOverride = nil } + let creds = SeedMockCredential() + creds.results["https://github.com/org/repo.git"] = .success( + GitHTTPSCredentials(username: "u", password: "super-secret-leak") + ) + var warnings: [String] = [] + let prev = StatusPrinter.onWarning + StatusPrinter.onWarning = { warnings.append($0) } + defer { StatusPrinter.onWarning = prev } + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try StartCommand.run( + options: StartOptions(name: resolved.containerName), + runtime: runtime, + isTTY: false, + credentials: creds + ) + try MiniTest.expect(!rebuilt, "credential refresh must not enter start recovery") + try MiniTest.expect(!warnings.joined().contains("super-secret-leak")) + try MiniTest.expect(!warnings.joined().contains("user:tok")) + try MiniTest.expect(!warnings.isEmpty) + try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("echo start-postStart") == true }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) + }), + ("refreshCredentialsRunningUsesStdin", { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20", "remoteUser": "vscode" }"#) + 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.labelRemoteUser] = "vscode" + let entry = MockProcessRunner.containerListJSON( + id: resolved.containerName, 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 == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let folder = labels[ContainerIdentity.labelLocalFolder] ?? ws.path + let password = "cmd-refresh-secret" + let host = RecordingHostProcessRunner() + host.handler = { call in + if call.arguments == ["-C", folder, "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + let creds = SeedMockCredential() + creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( + GitHTTPSCredentials(username: "plantsuite", password: password) + ) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try RefreshCredentialsCommand.run( + name: resolved.containerName, + runtime: runtime, + credentials: creds + ) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" || $0.arguments.first == "stop" }) + let seeds = mock.calls.filter { + $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true + } + try MiniTest.expectEqual(seeds.count, 1) + try MiniTest.expect(!seeds[0].arguments.contains { $0.contains(password) }) + try MiniTest.expect(seeds[0].environment?.values.contains(password) != true) + try MiniTest.expect((seeds[0].arguments.last ?? "").contains("useHttpPath true")) + try MiniTest.expect(!(seeds[0].arguments.last ?? "").contains("helper \"\"")) + try MiniTest.expectEqual( + String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", + "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=\(password)\n\n" + ) + try MiniTest.expect(!host.calls.contains { + $0.arguments.contains("fetch") || $0.arguments.contains("pull") || $0.arguments.contains("push") + }) + }), + ("refreshCredentialsVolumeUsesStampedGitURL", { + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, + ContainerIdentity.labelLocalFolder: "volume://adev-app-ws", + ContainerIdentity.labelGitURL: "https://dev.azure.com/org/proj/_git/repo", + ContainerIdentity.labelRemoteUser: "vscode" + ] + let entry = MockProcessRunner.containerListJSON( + id: "vol-app", 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 == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let host = RecordingHostProcessRunner() + host.handler = { _ in + ProcessResult(exitCode: 0, stdout: Data("should-not-run".utf8), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + let creds = SeedMockCredential() + creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( + GitHTTPSCredentials(username: "plantsuite", password: "vol-secret") + ) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try RefreshCredentialsCommand.run(name: "vol-app", runtime: runtime, credentials: creds) + try MiniTest.expect(host.calls.isEmpty, "volume refresh must not enumerate host remotes") + try MiniTest.expectEqual(creds.fillCalls, ["https://dev.azure.com/org/proj/_git/repo"]) + try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.contains { $0.contains("vol-secret") } }) + }), + ("refreshCredentialsStoppedDoesNotStart", { + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelLocalFolder: "/work/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "stopped-app", 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()) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + try RefreshCredentialsCommand.run(name: "stopped-app", runtime: runtime) + }) { error in + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.containerNotRunning) + try MiniTest.expect(err.hint?.contains("start --name stopped-app") == true) + } + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "stop" }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "exec" }) + }), + ("refreshCredentialsExecFailureIsRedacted", { + let labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: "/work/app" + ] + let entry = MockProcessRunner.containerListJSON( + id: "run-app", 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 == "exec" { + return ProcessResult( + exitCode: 1, + stdout: Data(), + stderr: Data("password=super-secret-leak https://user:tok@dev.azure.com/x\n".utf8) + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + ] + let host = RecordingHostProcessRunner() + host.handler = { call in + if call.arguments == ["-C", "/work/app", "remote", "-v"] { + return ProcessResult( + exitCode: 0, + stdout: Data("origin\thttps://dev.azure.com/org/proj/_git/repo (fetch)\n".utf8), + stderr: Data() + ) + } + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let restore = RecordingHostProcessRunner.install(host) + defer { restore() } + let creds = SeedMockCredential() + creds.results["https://dev.azure.com/org/proj/_git/repo"] = .success( + GitHTTPSCredentials(username: "plantsuite", password: "super-secret-leak") + ) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + try RefreshCredentialsCommand.run(name: "run-app", runtime: runtime, credentials: creds) + }) { error in + let err = error as! CLIError + try MiniTest.expect(err.message.contains("refresh")) + try MiniTest.expect(!err.message.contains("super-secret-leak")) + try MiniTest.expect(!err.message.contains("user:tok")) + try MiniTest.expect(!(err.hint ?? "").contains("super-secret-leak")) + } + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" || $0.arguments.first == "stop" }) + }), + ("refreshCredentialsHelpAndUsage", { + let usage = CommandSurface.usageText() + try MiniTest.expect(usage.contains("refresh-credentials")) + let help = CommandSurface.commandHelpText("refresh-credentials") ?? "" + try MiniTest.expect(help.contains("refresh-credentials [--name")) + try MiniTest.expect(help.contains("start --name")) + try MiniTest.expect(help.contains("stdin")) + try MiniTest.expect(help.contains("fetch")) + try MiniTest.expect(help.contains("useHttpPath")) + try MiniTest.expect(CommandSurface.commandHelpText("start")?.contains("refreshes git credentials") == true) + try MiniTest.expect(CommandSurface.commandHelpText("up")?.contains("refreshes git credentials") == true) }) ] @@ -3384,7 +3900,10 @@ nonisolated(unsafe) let phase4CommandTests: [(String, () throws -> Void)] = [ let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) var events: [String] = [] let host = RecordingHostProcessRunner() - host.handler = { _ in + host.handler = { call in + if call.arguments.contains("remote") { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } events.append("initialize") return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } diff --git a/Tests/adevcontainerTests/AllUnitTests.swift b/Tests/adevcontainerTests/AllUnitTests.swift index 5fcb12a..039db8a 100644 --- a/Tests/adevcontainerTests/AllUnitTests.swift +++ b/Tests/adevcontainerTests/AllUnitTests.swift @@ -6627,16 +6627,33 @@ nonisolated(unsafe) let featuresUnitTests: [(String, () throws -> Void)] = [ /// Runs the generated credential helper via `sh` (Linux test env) with HOME pinned /// to a scratch dir, feeding `input` on stdin like git's credential protocol. -func runCredentialHelper(helperPath: String, action: String, home: String, input: String) throws -> ProcessResult { - try FoundationProcessRunner().run( +func runCredentialHelper( + helperPath: String, + action: String, + home: String, + input: String, + path: String? = nil +) throws -> ProcessResult { + let resolvedPath = path ?? ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" + return try FoundationProcessRunner().run( executable: "/bin/sh", arguments: [helperPath, action], - environment: ["HOME": home], + environment: ["HOME": home, "PATH": resolvedPath], currentDirectory: nil, stdinData: Data(input.utf8) ) } +func credentialJWT(payloadJSON: String) -> String { + func b64(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64(#"{"alg":"none"}"#)).\(b64(payloadJSON)).sig" +} + func makeSeedScriptFixture() throws -> (root: URL, home: URL, inheritedHome: URL, environment: [String: String]) { let root = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) let home = root.appendingPathComponent("passwd-home", isDirectory: true) @@ -7265,17 +7282,19 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void let azure = GuestGitCredentialSeed.seedScript(appendGlobalHelper: false, configureAzureHost: true) try MiniTest.expect(!azure.contains("git config --global --replace-all credential.helper")) try MiniTest.expect(!azure.contains("git config --global --add credential.helper")) - try MiniTest.expect(azure.contains("git config --global --replace-all credential.https://dev.azure.com.helper \"\"")) - try MiniTest.expect(azure.contains("git config --global --add credential.https://dev.azure.com.helper")) + try MiniTest.expect(!azure.contains("credential.https://dev.azure.com.helper \"\""), "Azure seed must not write an empty helper reset") + try MiniTest.expect(!azure.contains("credential.https://dev.azure.com.helper ''")) + try MiniTest.expect(azure.contains("git config --global --unset-all \"$azure_key\""), "empty URL-scoped helper values are removed") + try MiniTest.expect(azure.contains("git config --global --add \"$azure_key\" \"$HELPER\"")) try MiniTest.expect(azure.contains("credential.https://dev.azure.com.useHttpPath true")) - guard let reset = azure.range(of: "credential.https://dev.azure.com.helper \"\"")?.lowerBound, - let added = azure.range(of: "git config --global --add credential.https://dev.azure.com.helper")?.lowerBound, + guard let removed = azure.range(of: "git config --global --unset-all \"$azure_key\"")?.lowerBound, + let added = azure.range(of: "git config --global --add \"$azure_key\" \"$HELPER\"")?.lowerBound, let usePath = azure.range(of: "credential.https://dev.azure.com.useHttpPath")?.lowerBound, let approve = azure.range(of: "git credential approve")?.lowerBound else { throw MiniTest.Failure(message: "Azure seed script is missing the URL-scoped helper") } - try MiniTest.expect(reset < added, "empty URL-scoped helper precedes the product helper") + try MiniTest.expect(removed < added, "empty helper removal precedes ensuring the product helper") try MiniTest.expect(added < usePath, "product helper is configured before useHttpPath") try MiniTest.expect(usePath < approve, "useHttpPath is set before approve") }), @@ -7619,14 +7638,22 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void "stdout carries only credential-protocol lines: \(line)" ) } - // Erase is a silent no-op. + // Erase removes the matching protocol+host entry and stays silent. let erase = try runCredentialHelper( helperPath: helperPath.path, action: "erase", home: home, - input: "protocol=https\nhost=h.example\n\n" + input: "protocol=https\nhost=h.example\nusername=other\npath=ignored\n\n" ) try MiniTest.expect(erase.succeeded) try MiniTest.expect(erase.stdoutString.isEmpty) try MiniTest.expect(erase.stderrString.isEmpty) + try MiniTest.expect(!erase.stdoutString.contains("hunter2@secret")) + let after = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=h.example\n\n" + ) + try MiniTest.expect(after.succeeded) + try MiniTest.expect(after.stdoutString.isEmpty, "erase removed the matching store entry") + try MiniTest.expect(!((try? String(contentsOf: dir.appendingPathComponent(".adevcontainer/git-credentials"), encoding: .utf8)) ?? "").contains("hunter2@secret")) }), ("guestSeedScriptConfigAppendsHelperAndDropsStore", { let script = GuestGitCredentialSeed.seedScript() @@ -7679,10 +7706,9 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void .split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) try MiniTest.expect(azureLines.contains("credential.helper=pre-existing"), "Azure seed leaves the global helper") - try MiniTest.expect(azureLines.contains("credential.https://dev.azure.com.helper=")) - try MiniTest.expect(azureLines.contains( - "credential.https://dev.azure.com.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev" - )) + try MiniTest.expect(!azureLines.contains("credential.https://dev.azure.com.helper="), "Azure seed does not write an empty helper") + let productHelper = "credential.https://dev.azure.com.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev" + try MiniTest.expectEqual(azureLines.filter { $0 == productHelper }.count, 1, "product helper is present once") try MiniTest.expect(azureLines.contains("credential.https://dev.azure.com.useHttpPath=true")) try MiniTest.expect(!azureLines.contains("credential.helper="), "Azure seed does not empty the global helper") }), @@ -7711,10 +7737,206 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void input: "protocol=https\nhost=trailing.example\n\n" ) try MiniTest.expect(erase.succeeded, "erase exits 0") + try MiniTest.expect(erase.stdoutString.isEmpty) + try MiniTest.expect(erase.stderrString.isEmpty) + let after = try String(contentsOf: storePath, encoding: .utf8) + try MiniTest.expect(!after.contains("p@ss:raw"), "erase removes the trailing protocol+host block") + let mode = try FileManager.default.attributesOfItem(atPath: storePath.path) + try MiniTest.expectEqual((mode[.posixPermissions] as? NSNumber)?.intValue, 0o600, "erase rewrite is 0600") + }), + ("guestSeedHelperExpiredJWTIsMissAndRemoved", { + let helper = GuestGitCredentialSeed.helperScript() + let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) + defer { try? FileManager.default.removeItem(at: dir) } + let helperPath = dir.appendingPathComponent("git-credential-adev") + try Data(helper.utf8).write(to: helperPath) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) + let home = dir.path + let expired = credentialJWT(payloadJSON: #"{"exp":1000,"sub":"x"}"#) + let skew = credentialJWT(payloadJSON: "{\"exp\":\(Int(Date().timeIntervalSince1970) + 30)}") + let valid = credentialJWT(payloadJSON: "{\"exp\":\(Int(Date().timeIntervalSince1970) + 3600)}") + let noExp = credentialJWT(payloadJSON: #"{"sub":"x"}"#) + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=\(expired)\n\n" + ) + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=github.com\nusername=alice\npassword=keep-me\n\n" + ) + let miss = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=dev.azure.com\nusername=Y\npath=org/proj/_git/repo\n\n" + ) + try MiniTest.expect(miss.succeeded) + try MiniTest.expect(miss.stdoutString.isEmpty, "expired JWT is a miss") + try MiniTest.expect(miss.stderrString.isEmpty) + try MiniTest.expect(!miss.stdoutString.contains(expired)) + let store = try String(contentsOf: dir.appendingPathComponent(".adevcontainer/git-credentials"), encoding: .utf8) + try MiniTest.expect(!store.contains(expired), "expired entry is removed") + try MiniTest.expect(store.contains("password=keep-me"), "other host remains") + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=\(skew)\n\n" + ) + let skewGet = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=dev.azure.com\n\n" + ) + try MiniTest.expect(skewGet.stdoutString.isEmpty, "exp within 60 seconds is expired") + try MiniTest.expect(skewGet.stderrString.isEmpty) + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=dev.azure.com\nusername=X\npassword=\(valid)\n\n" + ) + let kept = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=dev.azure.com\nusername=Y\n\n" + ) + try MiniTest.expect(kept.stdoutString.contains("username=Y")) + try MiniTest.expect(kept.stdoutString.contains("password=\(valid)"), "future exp is returned") + try MiniTest.expect(kept.stderrString.isEmpty) + for line in kept.stdoutString.split(whereSeparator: \.isNewline) where !line.isEmpty { + try MiniTest.expect( + line.hasPrefix("protocol=") || line.hasPrefix("host=") + || line.hasPrefix("username=") || line.hasPrefix("password="), + "password stays inside the credential protocol" + ) + } + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=other.example\nusername=u\npassword=\(noExp)\n\n" + ) + let missingExp = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=other.example\n\n" + ) + try MiniTest.expect(missingExp.stdoutString.contains("password=\(noExp)"), "missing exp returns the password") + for password in ["plain-token", "foo.bar", "a.b.c.d", "aaa.!!!.bbb"] { + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=literal.example\nusername=u\npassword=\(password)\n\n" + ) + let literal = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=literal.example\nusername=queried\n\n" + ) + try MiniTest.expect(literal.stdoutString.contains("password=\(password)"), "non-JWT \(password) is returned") + try MiniTest.expect(literal.stderrString.isEmpty) + } + _ = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, + input: "protocol=https\nhost=nob64.example\nusername=u\npassword=\(expired)\n\n" + ) + let noBase64 = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=nob64.example\n\n", + path: "/bin" + ) + try MiniTest.expect(noBase64.stdoutString.contains("password=\(expired)"), "unavailable base64 returns the password") + try MiniTest.expect(noBase64.stderrString.isEmpty) + }), + ("guestSeedHelperEraseRemovesHostAndKeepsOthers", { + let helper = GuestGitCredentialSeed.helperScript() + let dir = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) + defer { try? FileManager.default.removeItem(at: dir) } + let helperPath = dir.appendingPathComponent("git-credential-adev") + let storePath = dir.appendingPathComponent(".adevcontainer/git-credentials") + try Data(helper.utf8).write(to: helperPath) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath.path) + let home = dir.path + for input in [ + "protocol=https\nhost=dev.azure.com\nusername=one\npassword=p1\n\n", + "protocol=https\nhost=dev.azure.com\nusername=two\npassword=p2\n\n", + "protocol=https\nhost=github.com\nusername=alice\npassword=keep\n\n" + ] { + let stored = try runCredentialHelper( + helperPath: helperPath.path, action: "store", home: home, input: input + ) + try MiniTest.expect(stored.succeeded) + } + let erase = try runCredentialHelper( + helperPath: helperPath.path, action: "erase", home: home, + input: "protocol=https\nhost=dev.azure.com\nusername=nobody\npath=org/proj/_git/repo\n\n" + ) + try MiniTest.expect(erase.succeeded) + try MiniTest.expect(erase.stdoutString.isEmpty) + try MiniTest.expect(erase.stderrString.isEmpty) + let left = try String(contentsOf: storePath, encoding: .utf8) + try MiniTest.expect(!left.contains("dev.azure.com")) + try MiniTest.expect(!left.contains("password=p1")) + try MiniTest.expect(!left.contains("password=p2")) + try MiniTest.expect(left.contains("host=github.com")) + try MiniTest.expect(left.contains("password=keep")) + let mode = try FileManager.default.attributesOfItem(atPath: storePath.path) + try MiniTest.expectEqual((mode[.posixPermissions] as? NSNumber)?.intValue, 0o600) + let noMatch = try runCredentialHelper( + helperPath: helperPath.path, action: "erase", home: home, + input: "protocol=https\nhost=example.com\n\n" + ) + try MiniTest.expect(noMatch.succeeded) + try MiniTest.expectEqual(try String(contentsOf: storePath, encoding: .utf8), left, "no-match erase leaves the store") + }), + ("guestSeedAzureChainDropsEmptyHelperAndKeepsOneProductHelper", { + let fixture = try makeSeedScriptFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let configPath = fixture.home.appendingPathComponent("git-config") + let helper = "\(fixture.home.path)/.adevcontainer/git-credential-adev" + try """ + credential.helper=osxkeychain + credential.https://dev.azure.com.helper= + credential.https://dev.azure.com.helper=/opt/vscode/git-credential + credential.https://dev.azure.com.helper=\(helper) + credential.https://dev.azure.com.helper=\(helper) + core.askpass=keep + """.write(to: configPath, atomically: true, encoding: .utf8) + let script = GuestGitCredentialSeed.seedScript(appendGlobalHelper: false, configureAzureHost: true) + for _ in 0..<2 { + let result = try FoundationProcessRunner().run( + executable: "/bin/sh", + arguments: ["-c", script], + environment: fixture.environment, + currentDirectory: nil, + stdinData: Data("protocol=https\nhost=dev.azure.com\nusername=u\npassword=p\n\n".utf8) + ) + try MiniTest.expect(result.succeeded, "Azure chain cleanup succeeds") + try MiniTest.expect(!result.stdoutString.contains("password=p")) + try MiniTest.expect(!result.stderrString.contains("password=p")) + } + let lines = try String(contentsOf: configPath, encoding: .utf8) + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + try MiniTest.expect(lines.contains("credential.helper=osxkeychain"), "global helper list is not reset") + try MiniTest.expect(!lines.contains("credential.helper=")) + try MiniTest.expect(!lines.contains("credential.https://dev.azure.com.helper="), "empty helper reset is removed") + try MiniTest.expect(lines.contains("credential.https://dev.azure.com.helper=/opt/vscode/git-credential")) try MiniTest.expectEqual( - try String(contentsOf: storePath, encoding: .utf8), stored, - "erase leaves stored credentials intact" + lines.filter { $0 == "credential.https://dev.azure.com.helper=\(helper)" }.count, + 1, + "product helper is present exactly once" ) + try MiniTest.expect(lines.contains("credential.https://dev.azure.com.useHttpPath=true")) + try MiniTest.expect(lines.contains("core.askpass=keep")) + try MiniTest.expect( + !FileManager.default.fileExists(atPath: fixture.home.appendingPathComponent(".adevcontainer/.azure-helper-values").path), + "helper-list temp file is removed" + ) + }), + ("guestCredentialSourcesBindVsVolume", { + let (bindWorkspace, bindURL) = GuestGitCredentialSeed.credentialSources(from: [ + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeBind, + ContainerIdentity.labelLocalFolder: "/work/app", + ContainerIdentity.labelGitURL: "https://example.com/ignored.git" + ]) + try MiniTest.expectEqual(bindWorkspace, "/work/app") + try MiniTest.expectEqual(bindURL, nil) + let (volumeWorkspace, volumeURL) = GuestGitCredentialSeed.credentialSources(from: [ + ContainerIdentity.labelWorkspaceMode: ContainerIdentity.workspaceModeVolume, + ContainerIdentity.labelLocalFolder: "volume://adev-app-ws", + ContainerIdentity.labelGitURL: "https://dev.azure.com/org/proj/_git/repo" + ]) + try MiniTest.expectEqual(volumeWorkspace, nil) + try MiniTest.expectEqual(volumeURL, "https://dev.azure.com/org/proj/_git/repo") }) ] diff --git a/Tests/adevcontainerTests/CloneInVolumeTests.swift b/Tests/adevcontainerTests/CloneInVolumeTests.swift index 30db61e..351bf17 100644 --- a/Tests/adevcontainerTests/CloneInVolumeTests.swift +++ b/Tests/adevcontainerTests/CloneInVolumeTests.swift @@ -2827,6 +2827,7 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ let script = seeds[0].arguments.last ?? "" try MiniTest.expect(script.contains("credential.https://dev.azure.com.helper")) try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) + try MiniTest.expect(!script.contains("credential.https://dev.azure.com.helper \"\""), "Azure clone must not install an empty helper reset") try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) try MiniTest.expect(!script.contains("git config --global --add credential.helper")) try MiniTest.expect(!script.contains("credential.helper store")) diff --git a/Tests/adevcontainerTests/VSCodeOpenTests.swift b/Tests/adevcontainerTests/VSCodeOpenTests.swift index dc9f60f..2829734 100644 --- a/Tests/adevcontainerTests/VSCodeOpenTests.swift +++ b/Tests/adevcontainerTests/VSCodeOpenTests.swift @@ -894,7 +894,10 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = defer { try? FileManager.default.removeItem(at: ws) } let host = RecordingHostProcessRunner() var events: [String] = [] - host.handler = { _ in + host.handler = { call in + if call.arguments.contains("remote") { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } events.append("initialize") return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) } @@ -936,10 +939,12 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = 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")) + let initializeCalls = host.calls.filter { !$0.arguments.contains("remote") } + try MiniTest.expectEqual(initializeCalls.count, 1) + try MiniTest.expect(initializeCalls[0].arguments.contains("echo init-start-bind")) + try MiniTest.expect(host.calls.contains { $0.arguments.contains("remote") }, "real start refreshes host git remotes") try MiniTest.expectEqual( - (host.calls[0].currentDirectory as NSString?)?.standardizingPath, + (initializeCalls[0].currentDirectory as NSString?)?.standardizingPath, (ws.path as NSString).standardizingPath ) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) @@ -1107,7 +1112,11 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = } 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.expectEqual( + host.calls.filter { !$0.arguments.contains("remote") }.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") diff --git a/Tests/adevcontainerTests/main.swift b/Tests/adevcontainerTests/main.swift index c18c2ce..b08408b 100644 --- a/Tests/adevcontainerTests/main.swift +++ b/Tests/adevcontainerTests/main.swift @@ -33,6 +33,7 @@ let allTests: [(String, () throws -> Void)] = [] + doctorTests + containerPluginTests + upTests + + credentialRefreshCommandTests + execTests + lifecycleTests + phase1Tests diff --git a/specs/core.md b/specs/core.md index d6f6f30..ed7fee0 100644 --- a/specs/core.md +++ b/specs/core.md @@ -763,9 +763,11 @@ See also: [lifecycle-hooks.md](lifecycle-hooks.md) for hook surface details; [vs On the create paths — `up` fresh create (bind mode) and `rebuild` replacement create (bind and volume mode) — the CLI MUST seed the resolved remote connection user's git credential store in the container BEFORE any create-path lifecycle hook (onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand) runs. Seeding MUST run after the create-path ownership steps. -Seeding MUST write a POSIX-sh credential helper script to the connection user's home directory in the container (e.g. `$HOME/.adevcontainer/git-credential-adev`) with mode 0700, owned by the connection user. Seeding MUST configure the helper at `--global` scope via `git config --global --add credential.helper `; the configuration MUST append and MUST NOT replace pre-existing credential helpers. Seeding MUST add one credential entry per unique (protocol, host, username) triple via `git credential approve`, which routes through the configured helper whose store mode persists the entry. Entries MUST NOT include a path component, so sibling repositories on the same host are covered without knowing their paths. Matching uses the URL scheme (protocol), host, and username; SSH remotes MUST NOT be seeded. +Seeding MUST write a POSIX-sh credential helper script to the connection user's home directory in the container (e.g. `$HOME/.adevcontainer/git-credential-adev`) with mode 0700, owned by the connection user. For non-Azure HTTPS hosts, seeding MUST configure the helper at `--global` scope via `git config --global --add credential.helper `; the configuration MUST append and MUST NOT replace pre-existing credential helpers. Seeding MUST add one credential entry per unique (protocol, host, username) triple via `git credential approve`, which routes through the configured helper whose store mode persists the entry. Entries MUST NOT include a path component, so sibling repositories on the same host are covered without knowing their paths. Matching uses the URL scheme (protocol) and host; the queried username and path are ignored on get. SSH remotes MUST NOT be seeded. -The helper MUST implement `get`, `store`, and `erase`. On `get`, the helper MUST match the persisted store by (protocol, host) IGNORING the queried username, and MUST return the queried username (or the stored username when the query carries none) together with the stored password; when no entry matches, the helper MUST exit 0 with no output so git falls through to remaining helpers, askpass, or prompt. On `store`, the helper MUST persist the entry, deduping by (protocol, host, username), to its own store file (e.g. `$HOME/.adevcontainer/git-credentials`) with mode 0600, owned by the connection user; the helper MAY use git's store file format with percent-encoding or its own simple line format, and persisted values MUST round-trip raw username and password values (including `@` and `:` characters) without corruption. `erase` MUST be a no-op. Secrets MUST NOT be echoed to stdout or stderr outside the credential protocol and MUST NOT appear in argv. +For `dev.azure.com`, every seed and refresh MUST NOT write an empty `credential.https://dev.azure.com.helper` value and MUST NOT reset the global `credential.helper` list. It MUST remove an existing empty value for that URL-scoped key so an old empty reset stops blocking fallthrough. It MUST ensure the product helper path is present exactly once, and MUST set `credential.https://dev.azure.com.useHttpPath` true. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the organization is in the URL path; the CLI MUST NOT copy host `credential.helper` (`osxkeychain` / GCM) into the guest and MUST NOT proxy pathless queries to GCM. + +The helper MUST implement `get`, `store`, and `erase`. On `get`, the helper MUST match the persisted store by (protocol, host) IGNORING the queried username and path, and MUST return the queried username (or the stored username when the query carries none) together with the stored password; when no entry matches, the helper MUST exit 0 with no output so git falls through to remaining helpers, askpass, or prompt. If the matched password is a JWT (exactly two dots, three segments) and the payload decodes and contains a numeric `exp` less than or equal to now plus 60 seconds, `get` MUST exit 0 with no output and MUST remove that store entry so the expired token is not replayed. If the password is not a JWT, `exp` is missing, decode fails, or base64 is unavailable, `get` MUST return the stored password. The helper MUST NOT print the password outside the credential protocol. On `store`, the helper MUST persist the entry, deduping by (protocol, host, username), to its own store file (e.g. `$HOME/.adevcontainer/git-credentials`) with mode 0600, owned by the connection user; the helper MAY use git's store file format with percent-encoding or its own simple line format, and persisted values MUST round-trip raw username and password values (including `@` and `:` characters) without corruption. On `erase`, the helper MUST remove every store entry matching protocol and host, ignoring username and path, by an atomic rewrite that leaves the store mode 0600. No match MUST exit 0. `erase` MUST NOT be a no-op. Secrets MUST NOT be echoed to stdout or stderr outside the credential protocol and MUST NOT appear in argv or the environment. Credentials MUST be acquired on the HOST through the shared acquisition contract declared by **In-container full clone populate (auth by URL scheme)** ([clone.md](clone.md)): `git credential fill` (protocol/host/path from URL) with `GIT_TERMINAL_PROMPT=0`, optional `ADEVCONTAINER_GIT_TOKEN`, and the `gh auth token` fallback — the existing `HostGitCredential.fillHTTPS` path. When fill returns nil for a URL, the CLI MUST skip that URL silently. @@ -780,15 +782,17 @@ Seeding failures (for example missing in-container git or an exec failure) MUST Secrets MUST NEVER appear in success JSON, labels, StatusPrinter progress lines, or logged errors; errors MUST redact URL userinfo and credential material (same redaction as the clone flow). -Non-create paths MUST NOT seed: `up` reuse of a running matching container, `up` start-stopped, and bare `start` MUST NOT run seeding. +A real `start` (stopped → running) and `up` start-stopped MUST re-seed the same way after the container is running and before restart postStart. Bind mode uses the host workspace's HTTPS fetch remotes; volume mode uses the stamped `devcontainer.git_url`. Silent skip — no warning and no failure — MUST apply when host git is missing, when there are no HTTPS remotes, or when fill returns nil. Refresh failure MUST soft-fail with a redacted stderr warning and MUST NOT fail start or up, delete the container, or enter recovery. Already-running `start` and `up` reuse of an already-running container MUST NOT re-seed. + +`adevcontainer refresh-credentials [--name]` MUST use the same managed selection as `start` (interactive picker or `--name`). The target MUST already be running. If it is stopped, the command MUST fail with a structured error whose hint names `start --name ` and MUST NOT start or stop the container. An exec failure MUST fail the command with a redacted error. The command MUST NOT run git fetch, pull, or push, and MUST NOT write the workspace. Credential material MUST enter the guest via stdin only, never argv or environment. -`clone` is covered-by-design: the product MUST NOT add a second seeding mechanism on `clone`; clone's populate already configures `credential.helper store` + approve before hooks, and regression coverage MUST prove the store outcome is delivered without a seeding call. +`clone` keeps its existing populate path. Non-Azure clone configures local `credential.helper store` plus pathless approve and MUST NOT install the product helper. Azure clone MUST install the same URL-scoped product helper via `installHTTPSCredential` (no second mechanism, no empty helper reset, `useHttpPath` true) and MUST NOT use pathless `credential.helper store` for that host. #### Scenario: up bind fresh create seeds the store before hooks - Given a bind-mode `up` fresh create whose host workspace has an HTTPS fetch remote `https://dev.azure.com/plantsuite/PlantSuite/_git/GitOps` and host `git credential fill` returns credentials - When the create path runs after start and before create-path hooks -- Then the CLI acquires credentials on the host and runs an in-container exec as the connection user that writes the credential helper script, configures `credential.helper --add` with its absolute path at `--global` scope, and approves an entry for (https, dev.azure.com, plantsuite) BEFORE the first create-path hook exec +- Then the CLI acquires credentials on the host and runs an in-container exec as the connection user that writes the credential helper script, configures the `dev.azure.com` URL-scoped helper (no empty helper value, product helper present once, `useHttpPath` true) without resetting the global `credential.helper` list, and approves an entry for (https, dev.azure.com, plantsuite) BEFORE the first create-path hook exec #### Scenario: sibling repositories on the same host are covered @@ -844,11 +848,55 @@ Non-create paths MUST NOT seed: `up` reuse of a running matching container, `up` - When seeding runs - Then stderr carries a warning, the create path continues through hooks and succeeds absent other failures, the container is NOT deleted, and bring-up recovery is NOT entered -#### Scenario: non-create paths never seed +#### Scenario: expired JWT get is a miss and drops the entry -- Given a matching running bind-mode container, a stopped bind-mode container, or a bare `start` target -- When `up` reuses the running container, `up` starts the stopped one, or `start` runs -- Then no seeding exec runs +- Given a store entry whose password is a JWT (exactly two dots, three segments) and whose payload decodes to a numeric `exp` less than or equal to now plus 60 seconds +- When the helper handles `get` for that protocol and host +- Then the helper exits 0 with no output, removes that store entry, and does not print the password outside the credential protocol + +#### Scenario: undecodable or non-JWT passwords are still returned + +- Given a store entry whose password is not a JWT, or is a JWT whose `exp` is missing, whose payload does not decode, or for which base64 is unavailable +- When the helper handles `get` for that protocol and host +- Then the helper returns the stored password with the queried username (or the stored username when the query has none) + +#### Scenario: erase removes protocol and host matches + +- Given a store with two entries for the same protocol and host and one entry for another host +- When the helper handles `erase` for that protocol and host, including a query that names a different username or a path +- Then both matching entries are removed by an atomic rewrite, the other host remains, the store mode is 0600, and stdout and stderr are empty +- And an erase with no match exits 0 and leaves the store unchanged + +#### Scenario: Azure seed removes an empty helper and does not write one + +- Given a global git config that already has an empty `credential.https://dev.azure.com.helper` value and another non-empty helper for that URL +- When Azure seeding or refresh configures the URL-scoped helper +- Then the empty value is gone, the product helper path is present exactly once, `credential.https://dev.azure.com.useHttpPath` is true, pre-existing global `credential.helper` values remain, and no empty helper value is written + +#### Scenario: refresh-credentials refreshes a running container + +- Given a running managed container and host `git credential fill` returns a credential for its bind remotes or stamped git URL +- When the user runs `adevcontainer refresh-credentials --name ` +- Then the CLI execs the seed script in that container with the credential on stdin only, does not start or stop the container, and does not fetch, pull, push, or write the workspace + +#### Scenario: refresh-credentials refuses a stopped container + +- Given a stopped managed container +- When the user runs `adevcontainer refresh-credentials --name ` +- Then the command fails with a structured error whose hint names `start --name `, and the CLI does not start, stop, or exec the container + +#### Scenario: real start and up start-stopped refresh and soft-fail + +- Given a stopped managed container +- When `adevcontainer start` or `up` starts it and the credential exec fails +- Then stderr carries a redacted warning, the command still succeeds absent other failures, the container is not deleted, and bring-up or start recovery is not entered +- And the refresh runs after the container is running and before restart postStart + +#### Scenario: already-running start and up reuse do not refresh + +- Given a matching running container +- When `up` reuses it, or `start` finds it already running +- Then no credential refresh exec runs #### Scenario: secrets are redacted on the seeding error path diff --git a/wiki/architecture.md b/wiki/architecture.md index 82af200..431c57b 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -68,7 +68,7 @@ Volume mode exists for better metadata I/O (git status, node_modules, many small | `doctor` | Host/runtime readiness; plugin layout. Checks-only (no restage). Missing plugin: PATH `adevcontainer plugin --install` (`sudo` when needed) — never `container dev plugin --install`. | | `plugin --install` / `plugin --uninstall` | Install or remove the Apple plugin layout. `--install` writes an absolute symlink at `bin/dev` (not a Mach-O copy). `--uninstall` removes only `bin/dev` and `config.toml`. | | `up` | **Bind-mode** only: resolve config from host workspace, create/start/reuse; ensure named volumes; workspace bind; Features; lifecycle hooks in scope. Optional `--vscode` after success (see [VS Code flow](#vs-code-flow)). Reuse only when stamped config hash matches; mismatch → fail `config_hash_mismatch` (remediate with `rebuild`). Eligible bring-up failure with an editable host `devcontainer.json` → [bring-up recovery](#bring-up-recovery). Author synchronization is not part of `up` fresh-create. | -| `clone [--resume]` | **Volume-mode** workspace (the product's volume-mode counterpart of VS Code clone-in-volume): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot. Non-Azure unchanged — clone: local `credential.helper store` + pathless approve; up/rebuild: append-only global product helper, inherited helpers kept. `dev.azure.com` only: empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev`, and `credential.https://dev.azure.com.useHttpPath true`. Product helper matches protocol+host and ignores path. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM). No GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | +| `clone [--resume]` | **Volume-mode** workspace (the product's volume-mode counterpart of VS Code clone-in-volume): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot; guest credential contract: [`specs/core.md`](../specs/core.md) **Git credential store seeded on create paths**. No host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | | `rebuild [--name]` | **Forced-rebuild** path (realized in domain specs; archive [`20260810-rebuild`](../specs/changes/archive/20260810-rebuild/)): managed selection (`--name` / auto-single / picker); read **current** stamped `devcontainer.json` before any delete and capture the existing workspace-local Git author identity as the sole source of truth (bind: host workspace; volume: old container workspace); after config/host/Features succeed → container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `*-ws` (rebuild never deletes `*-ws`) and `${devcontainerId}` volumes; after replacement preparation, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; no host config or other container users changed; missing/incomplete → no invented value. User-literal sources stay as written (data preserved, never re-clone). Foreign occupant of the new name: do not delete selected; TTY Y/n + new name. Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: a name-indexed resume is published before delete, and `rebuild --name` opens that session when the container is gone and no helper exists, reusing the stamped `*-ws` without creating a blank workspace volume. A listed marked helper still wins. | | `list [--json]` | Managed containers only (`devcontainer.managed=adevcontainer`) | | `start [--name]` | Start a managed stopped container via `--name` or interactive picker. **Real start** (stopped→running, bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt (re-merge and run feature-contributed postStart on this start) → CLI-attach `postAttachCommand`. **Already-running start:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. Optional `--vscode` is **open only**. postAttach loads config from labels (bind: host `local_folder`+`config_file`; volume: in-container config path) and merges feature postAttach from image `devcontainer.metadata` (load errors → treat absent, do not fail start). Start failure recovery delegates to `rebuild --name` — does **not** re-run start or open an editor | diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 0bb7c2a..3fbd897 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -226,9 +226,9 @@ Contract: [`specs/core.md`](../../specs/core.md) (workspace parents + read-only **Symptom without fix:** remoteUser EACCES writing home-dir named volumes; lifecycle `mkdir` fails under root-owned intermediate parents. -### Git credential seeding (create paths) +### Git credential seeding -After ownership, before create-path hooks, `up` fresh create (bind) and `rebuild` replacement (both modes) seed the resolved connection user's username-agnostic guest Git credential helper (`GuestGitCredentialSeed`). Remote source: bind — host `git -C remote -v`, unique **HTTPS fetch** URLs only; rebuild volume — stamped `devcontainer.git_url`. HTTPS credentials are acquired on the host via the shared fill contract (`GIT_TERMINAL_PROMPT=0`; `ADEVCONTAINER_GIT_TOKEN` / `gh auth token` fallbacks), then one exec as the effective connection user resolves that user's HOME from passwd data rather than inherited HOME, writes `$HOME/.adevcontainer/git-credential-adev` mode 0700. Non-Azure HTTPS is unchanged: append-only `git config --global --add credential.helper ` (inherited helpers kept) and one pathless `git credential approve` per unique (protocol, host, username). `dev.azure.com` only gets a URL-scoped helper override — empty `credential.https://dev.azure.com.helper`, then that product helper — and `credential.https://dev.azure.com.useHttpPath true`; the global helper list is not reset. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM) into the guest. The helper's private store is `$HOME/.adevcontainer/git-credentials` mode 0600; `get` matches protocol+host and ignores path and the queried username, returning that username (or the stored username when the query has none) with the stored password, so sibling repos on the same host are covered and a `useHttpPath` query still hits the import. Guest `~/.gitconfig` lives on the container rootfs, so rebuild refreshes it; the workspace volume keeps `.git/config`, which does not hold this helper. Seeded credential material enters the container via stdin only, never argv or env; errors redact URL userinfo and credential material. Silent skips (no exec, no warning): host git missing, non-repo or no fetch remotes, fill nil (public remotes stay anonymous), stamped `git_url` missing. Failure **soft-fails** — stderr warning, create path continues, no delete, no bring-up recovery; hook failure stays the hook's own failure. Non-create paths (`up` reuse, `up` start-stopped, bare `start`) never seed. `clone` is separate. Non-Azure populate keeps local `credential.helper store` plus pathless approve and does not run this seed. `dev.azure.com` does not use that pathless store; after clone it runs this URL-scoped override and `useHttpPath`. +Contract: [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**. Implementation symbol: `GuestGitCredentialSeed`. **Test seam:** command tests driving `up` fresh-create or `rebuild` replacement that install `RecordingHostProcessRunner` via `LifecycleRunner.hostProcessRunnerOverride` must answer or tolerate the seeding probe call (`git -C remote -v`), same as the adapted initialize-command tests. @@ -246,7 +246,7 @@ After ownership, before create-path hooks, `up` fresh create (bind) and `rebuild 8. **Ownership + start:** scoped helper (when `--read-only` or dropped `CAP_CHOWN` selects it) chowns writable named volumes then the main container starts; otherwise start first and use the normal ownership path — see [Named volumes / ownership](#named-volumes-ensure--reuse--ownership). 9. **Populate (in-container full clone)** — happy path is **not** host full clone + tar-pipe (`copyTreeIntoContainer` may remain as unused utility). After Features ensure git. Reuse does not git-clone over a tree that already has entries; emptiness is an in-container listing after start (`lost+found` alone counts as empty): - Exec in-container `git clone` of the URL into `workspaceFolder` (as `remoteUser` when set); verify `workspaceFolder/.git`. - - **HTTPS auth:** host `git credential fill` (protocol/host/path; GCM/osxkeychain transparent — **no product GCM-in-guest**, no mount of host `~/.git-credentials`, do not copy host `credential.helper` into the guest). Optional fallbacks: `ADEVCONTAINER_GIT_TOKEN`; `gh auth token` for github.com when `gh` available. When creds exist → one-shot into guest clone via GIT_ASKPASS/env (never log secrets; redact errors) → non-Azure: local `credential.helper store` + pathless `git credential approve` once; `dev.azure.com`: no pathless store — the URL-scoped helper and `useHttpPath` in [Git credential seeding](#git-credential-seeding-create-paths). When fill empty → anonymous in-container clone (public); auth failure → structured hint to configure host credentials or use SSH. + - **HTTPS auth:** host `git credential fill` (protocol/host/path; GCM/osxkeychain transparent — **no product GCM-in-guest**, no mount of host `~/.git-credentials`, do not copy host `credential.helper` into the guest). Optional fallbacks: `ADEVCONTAINER_GIT_TOKEN`; `gh auth token` for github.com when `gh` available. When creds exist → one-shot into guest clone via GIT_ASKPASS/env (never log secrets; redact errors). Guest helper contract: [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**. When fill empty → anonymous in-container clone (public); auth failure → structured hint to configure host credentials or use SSH. - **SSH auth:** agent already forwarded via `--ssh` from create. - **Author apply:** when **both** name+email from step 4 → guest `git config --local` both; if either missing → warn once, no partial write; no synthetic defaults. On a kept tree, apply only when `.git` already exists. 10. Create-path lifecycle hooks (same order as fresh `up`). On start/populate/hook/ownership failure after create: delete the container; delete the workspace `*-ws` volume only when this invocation's `ensureVolume` created it. Reuse never deletes that volume. @@ -397,7 +397,7 @@ Each hook property admits **string** | **argv `string[]`** | **object map** `nam | Path | Hooks / apply | |------|----------------| -| 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; **Ownership** (`WorkspaceOwnership`) before hooks — eligible named volumes use the pre-start scoped helper when `--read-only` or dropped `CAP_CHOWN` selects it, otherwise the post-start main-container path; `up` fresh create + `rebuild` also handle rootfs workspace parents — see [ownership](#named-volumes-ensure--reuse--ownership); then **git credential seeding** (`up` fresh create + `rebuild` replacement; not `clone`) — soft-fail — see [git credential seeding](#git-credential-seeding-create-paths); then `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` → **settings+extensions apply** (soft-fail; not gated on `--vscode`); `waitFor` default `updateContentCommand` | +| 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; **Ownership** (`WorkspaceOwnership`) before hooks — eligible named volumes use the pre-start scoped helper when `--read-only` or dropped `CAP_CHOWN` selects it, otherwise the post-start main-container path; `up` fresh create + `rebuild` also handle rootfs workspace parents — see [ownership](#named-volumes-ensure--reuse--ownership); then **git credential seeding** — soft-fail — [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**; 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) | host `initializeCommand` → `postStartCommand` + feature remelt; then settings+extensions apply if pending (**not** `--vscode`-gated); CLI-attach postAttach | diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index ab70940..a7c3cbc 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -34,7 +34,7 @@ Facts that constrain the CLI. Not a full Apple container manual — only gaps th | Identity hash | workspace path + config path | normalized git URL + config relpath | | `local_folder` label | real host path | `volume://…` | | Start hooks | real start: host `initializeCommand` (if host path) + `postStart` + feature remelt (re-merge and run feature-contributed postStart on this start) + CLI-attach postAttach | same; volume start without host path skips initialize + warns | -| Auth for git | **HTTPS:** bind-mode create paths (`up` fresh create; `rebuild` bind) seed a username-agnostic POSIX-sh helper in the effective connection user's HOME before create-path hooks — HOME is resolved from the effective user, the helper is 0700, its private store is 0600. Non-Azure keeps an append-only global `credential.helper` (inherited helpers kept) and pathless approve. `dev.azure.com` only gets a URL-scoped override — empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev` — and `credential.https://dev.azure.com.useHttpPath true`. Helper `get` matches protocol+host and ignores path and the queried username, returning that username (or the stored username when the query has none) with the stored password, covering sibling repos. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM) into the guest. Host acquisition uses `git credential fill` (`ADEVCONTAINER_GIT_TOKEN` / `gh auth token` fallbacks); seeded credential material enters the guest via stdin only, never argv or env. Silent skip: host git missing / no repo or remotes / fill nil (public stays anonymous). Soft-fail warn. **Not seeded:** SSH remotes; non-create paths (`up` reuse, `up` start-stopped, bare `start`) | **SSH:** `SSH_AUTH_SOCK` + `create --ssh`. **HTTPS:** clone's separate populate uses host `git credential fill` one-shot. Non-Azure: local `credential.helper store` + pathless approve. `dev.azure.com`: the URL-scoped helper and `useHttpPath` in the bind cell, not pathless `credential.helper store`. No GCM-in-guest; no host `~/.git-credentials` mount; no host `credential.helper` copy; no PAT CLI primary UX | +| Auth for git | Guest HTTPS credential contract: [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**. **SSH:** `SSH_AUTH_SOCK` + `create --ssh` | **SSH:** `SSH_AUTH_SOCK` + `create --ssh`. **HTTPS:** host `git credential fill` at populate; guest helper contract: [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**. 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) | Detail: [architecture.md](../architecture.md), [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). Contract: [`specs/clone.md`](../../specs/clone.md) (volume vs bind); union of [`specs/.md`](../../specs/).