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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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-1147%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers)
[![tests](https://img.shields.io/badge/tests-1163%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).
Expand Down
90 changes: 84 additions & 6 deletions Sources/ADevContainerLib/Commands/CloneCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,18 @@ public enum CloneCommand {

if keepExistingTree {
StatusPrinter.status("Keeping existing workspace tree", item: identity.workspaceVolumeName)
// Reuse skips populate. Only Azure needs a fresh URL-scoped helper;
// other HTTPS hosts keep whatever local store the earlier clone wrote.
if Self.isAzureDevOpsHTTPS(url) {
Self.seedGuestGitCredentials(
url: url,
prepared: nil,
containerId: id,
connectionUser: connectionUser,
credentials: credentials,
runtime: runtime
)
}
} else {
StatusPrinter.status("Populating workspace volume")
do {
Expand Down Expand Up @@ -1353,16 +1365,20 @@ public enum CloneCommand {
execEnv["GIT_TERMINAL_PROMPT"] = "0"
execEnv["ADEV_CLONE_URL"] = effectiveCloneURL
execEnv["ADEV_CLONE_WORKDIR"] = workspaceFolder
let azure = Self.isAzureDevOpsHTTPS(url)
if let creds = httpsCreds {
execEnv["ADEV_CLONE_USER"] = creds.username
execEnv["ADEV_CLONE_PASS"] = creds.password
if let fields = GitURLClassifier.httpsCredentialFields(for: effectiveCloneURL) {
if !azure, let fields = GitURLClassifier.httpsCredentialFields(for: effectiveCloneURL) {
execEnv["ADEV_CLONE_PROTO"] = fields.protocolName
execEnv["ADEV_CLONE_HOST"] = fields.host
}
}

let script = inContainerCloneScript(configureHTTPSStore: httpsCreds != nil)
let script = inContainerCloneScript(
useHTTPSAskpass: httpsCreds != nil,
configureStore: httpsCreds != nil && !azure
)
// Live-tee populate git clone like lifecycle hooks / Features build (framed tool lines).
let result = try runtime.exec(
nameOrId: containerId,
Expand Down Expand Up @@ -1421,11 +1437,70 @@ public enum CloneCommand {
hint: "In-container git clone did not produce a .git directory"
)
}

if azure, let creds = httpsCreds {
seedGuestGitCredentials(
url: url,
prepared: creds,
containerId: containerId,
connectionUser: remoteUser,
credentials: credentials,
runtime: runtime
)
}
}

private static func isAzureDevOpsHTTPS(_ url: String) -> Bool {
guard let fields = GitURLClassifier.httpsCredentialFields(for: url) else { return false }
return GuestGitCredentialSeed.isAzureDevOpsHost(fields.host)
}

/// Azure-only guest helper. Non-Azure clone keeps local `credential.helper store`.
/// `prepared` is the credential populate already filled; nil fills again (reuse).
/// Soft-fail: a seed error must not delete a cloned workspace.
private static func seedGuestGitCredentials(
url: String,
prepared: GitHTTPSCredentials?,
containerId: String,
connectionUser: String?,
credentials: any GitCredentialProviding,
runtime: AppleContainerRuntime
) {
guard isAzureDevOpsHTTPS(url) else { return }
let seeder = GuestGitCredentialSeed(
credentials: credentials,
runner: LifecycleRunner.hostProcessRunnerOverride ?? FoundationProcessRunner()
)
do {
if let prepared {
try seeder.installHTTPSCredential(
containerId: containerId,
url: url,
credentials: prepared,
connectionUser: connectionUser,
runtime: runtime
)
} else {
try seeder.seed(
containerId: containerId,
hostWorkspace: nil,
gitURL: url,
connectionUser: connectionUser,
runtime: runtime
)
}
} catch {
StatusPrinter.warning(
"Git credential seeding failed; continuing without forwarded credentials: \(error.localizedDescription)"
)
}
}

/// Portable sh script: clone into workspace (handles lost+found-only volumes).
/// Credentials (when set) arrive via env ADEV_CLONE_USER / ADEV_CLONE_PASS — never printed.
private static func inContainerCloneScript(configureHTTPSStore: Bool) -> String {
/// Non-Azure HTTPS stores them with local `credential.helper store`. Azure does not:
/// `useHttpPath` makes a pathless store miss, so the caller seeds a URL-scoped helper.
private static func inContainerCloneScript(useHTTPSAskpass: Bool, configureStore: Bool) -> String {
// shellcheck-style: set -e; use env vars only.
var lines: [String] = [
"set -e",
Expand All @@ -1435,7 +1510,7 @@ public enum CloneCommand {
"export GIT_TERMINAL_PROMPT=0",
]

if configureHTTPSStore {
if useHTTPSAskpass {
lines += [
// One-shot ASKPASS: reads username/password from env at prompt time.
"ASKPASS=\"$(mktemp)\"",
Expand Down Expand Up @@ -1467,15 +1542,18 @@ public enum CloneCommand {
"rmdir \"$TMP\" 2>/dev/null || rm -rf \"$TMP\"",
]

if configureHTTPSStore {
// HTTPS after clone: store helper + approve once into ~/.git-credentials (user home layer).
if configureStore {
lines += [
"git -C \"$WS\" config credential.helper store",
"if [ -n \"${ADEV_CLONE_PROTO:-}\" ] && [ -n \"${ADEV_CLONE_HOST:-}\" ]; then",
" printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n' \\",
" \"$ADEV_CLONE_PROTO\" \"$ADEV_CLONE_HOST\" \"$ADEV_CLONE_USER\" \"$ADEV_CLONE_PASS\" \\",
" | git -C \"$WS\" credential approve",
"fi",
]
}
if useHTTPSAskpass {
lines += [
"rm -f \"$ASKPASS\"",
"trap - EXIT",
]
Expand Down
134 changes: 103 additions & 31 deletions Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import Foundation
/// never argv or environment. Silent no-op (no warning, no exec) when host git is missing, the
/// workspace has no remotes, fill returns nil, or the stamped URL is missing/empty.
/// 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.
public struct GuestGitCredentialSeed {
public var credentials: any GitCredentialProviding
public var runner: any ProcessRunning
Expand Down Expand Up @@ -75,30 +81,46 @@ public struct GuestGitCredentialSeed {
entries.append(entry)
}
}
guard !entries.isEmpty else { return }

let input = entries.map { entry in
"protocol=\(entry.protocolName)\nhost=\(entry.host)\nusername=\(entry.username)\npassword=\(entry.password)\n\n"
}.joined()

let result = try runtime.exec(
nameOrId: containerId,
command: ["sh", "-c", Self.seedScript()],
user: connectionUser,
env: [:],
stdinData: Data(input.utf8)
try install(
containerId: containerId,
entries: entries,
connectionUser: connectionUser,
runtime: runtime
)
guard result.succeeded else {
let safeHosts = entries.map(\.host).filter {
$0.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil
}
let detail = safeHosts.isEmpty ? "" : ": host=\(safeHosts.joined(separator: ","))"
throw CLIError(
code: CLIErrorCode.lifecycleFailed,
message: "Failed to seed git credentials in the container (exit \(result.exitCode))\(detail)",
hint: "Ensure in-container git is installed; the container still works without forwarded credentials"
)
}

/// Install the guest helper and approve one credential clone already filled on the host.
/// Does not call `fillHTTPS` again. Non-HTTPS URLs are a silent no-op.
public func installHTTPSCredential(
containerId: String,
url: String,
credentials: GitHTTPSCredentials,
connectionUser: String?,
runtime: AppleContainerRuntime
) throws {
guard GitURLClassifier.kind(of: url) == .https,
let fields = GitURLClassifier.httpsCredentialFields(for: url)
else {
return
}
try install(
containerId: containerId,
entries: [
Entry(
protocolName: fields.protocolName,
host: fields.host,
username: credentials.username,
password: credentials.password
)
],
connectionUser: connectionUser,
runtime: runtime
)
}

/// True only for the Azure DevOps host that keeps the organization in the URL path.
public static func isAzureDevOpsHost(_ host: String) -> Bool {
host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "dev.azure.com"
}

/// Unique fetch URLs from `git remote -v` output (`<name>\t<url> (fetch)` lines).
Expand All @@ -125,8 +147,9 @@ public struct GuestGitCredentialSeed {

/// The in-container POSIX-sh credential helper (get/store/erase) that the seed
/// script writes to `$HOME/.adevcontainer/git-credential-adev` (mode 0700) and
/// appends via `git config --global --add credential.helper`. `get` matches the
/// persisted store by (protocol, host) ignoring the queried username; `store`
/// 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
Expand Down Expand Up @@ -260,11 +283,13 @@ public struct GuestGitCredentialSeed {
].joined(separator: "\n")
}

/// One in-container script: write the username-agnostic credential helper to the
/// connection user's home, append it via `git config --global --add`, then approve
/// one entry per stdin credential block. Entries travel via stdin — never argv or env.
static func seedScript() -> String {
[
/// 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.
static func seedScript(appendGlobalHelper: Bool = true, configureAzureHost: Bool = false) -> String {
var lines = [
"set -e",
"uid=\"$(id -u 2>/dev/null)\"",
"resolved_home=\"\"",
Expand Down Expand Up @@ -299,7 +324,19 @@ public struct GuestGitCredentialSeed {
Self.helperScript(),
"ADEV_HELPER_EOF",
"chmod 700 \"$HELPER\" 2>/dev/null",
"git config --global --add credential.helper \"$HELPER\" >/dev/null 2>&1",
]
if appendGlobalHelper {
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.
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",
"git config --global credential.https://dev.azure.com.useHttpPath true >/dev/null 2>&1",
]
}
lines += [
"protocol=\"\"",
"host=\"\"",
"username=\"\"",
Expand Down Expand Up @@ -330,7 +367,42 @@ public struct GuestGitCredentialSeed {
" esac",
"done",
"approve"
].joined(separator: "\n")
]
return lines.joined(separator: "\n")
}

private func install(
containerId: String,
entries: [Entry],
connectionUser: String?,
runtime: AppleContainerRuntime
) throws {
guard !entries.isEmpty else { return }

let input = entries.map { entry in
"protocol=\(entry.protocolName)\nhost=\(entry.host)\nusername=\(entry.username)\npassword=\(entry.password)\n\n"
}.joined()

let azure = entries.contains { Self.isAzureDevOpsHost($0.host) }
let other = entries.contains { !Self.isAzureDevOpsHost($0.host) }
let result = try runtime.exec(
nameOrId: containerId,
command: ["sh", "-c", Self.seedScript(appendGlobalHelper: other, configureAzureHost: azure)],
user: connectionUser,
env: [:],
stdinData: Data(input.utf8)
)
guard result.succeeded else {
let safeHosts = entries.map(\.host).filter {
$0.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil
}
let detail = safeHosts.isEmpty ? "" : ": host=\(safeHosts.joined(separator: ","))"
throw CLIError(
code: CLIErrorCode.lifecycleFailed,
message: "Failed to seed git credentials in the container (exit \(result.exitCode))\(detail)",
hint: "Ensure in-container git is installed; the container still works without forwarded credentials"
)
}
}

private func bindFetchURLs(hostWorkspace: String) -> [String] {
Expand Down
2 changes: 1 addition & 1 deletion Sources/ADevContainerLib/Support/CommandSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ public enum CommandSurface {
--reuse-volume or --replace-volume and do not delete. Reuse keeps the tree
and never deletes that volume, including on later failure.
- SSH: needs ssh-agent (SSH_AUTH_SOCK); create --ssh for later push
- HTTPS: host git credential fill one-shot; guest credential.helper store
- HTTPS: host git credential fill one-shot; non-Azure guest credential.helper store; dev.azure.com uses a URL-scoped helper and useHttpPath
- Auto-adds Features git:1 when config lacks git/common-utils
- No --branch / PAT CLI / GCM-in-guest
- Bring-up recovery: on an eligible failure (resolve/create/start/ownership/
Expand Down
Loading
Loading