From a0e807e9ac20ec892ad17cbca26555825d7d0d37 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 2 Oct 2026 00:06:19 -0300 Subject: [PATCH 1/3] fix: scope Azure DevOps guest git credentials to the host In-container fetch against dev.azure.com failed with "Cannot determine the organization name" because the guest query was pathless while host Git Credential Manager requires the organization in the path. Seeding no longer replaces the global credential.helper list. dev.azure.com gets a URL-scoped helper override and useHttpPath. GitHub and other HTTPS hosts keep local store on clone and append-only helper on up/rebuild. --- .../Commands/CloneCommand.swift | 90 +++++++++- .../Git/GuestGitCredentialSeed.swift | 134 +++++++++++---- .../Support/CommandSurface.swift | 2 +- Tests/adevcontainerTests/AllUnitTests.swift | 159 ++++++++++++++++-- .../CloneInVolumeTests.swift | 93 ++++++++-- 5 files changed, 409 insertions(+), 69 deletions(-) diff --git a/Sources/ADevContainerLib/Commands/CloneCommand.swift b/Sources/ADevContainerLib/Commands/CloneCommand.swift index 1908c42..4a3bd71 100644 --- a/Sources/ADevContainerLib/Commands/CloneCommand.swift +++ b/Sources/ADevContainerLib/Commands/CloneCommand.swift @@ -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 { @@ -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, @@ -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", @@ -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)\"", @@ -1467,8 +1542,7 @@ 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", @@ -1476,6 +1550,10 @@ public enum CloneCommand { " \"$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", ] diff --git a/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift b/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift index ac706aa..110ec1f 100644 --- a/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift +++ b/Sources/ADevContainerLib/Git/GuestGitCredentialSeed.swift @@ -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 @@ -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 (`\t (fetch)` lines). @@ -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 @@ -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=\"\"", @@ -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=\"\"", @@ -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] { diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index 01e6440..086f2b7 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -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/ diff --git a/Tests/adevcontainerTests/AllUnitTests.swift b/Tests/adevcontainerTests/AllUnitTests.swift index de8ca02..5fcb12a 100644 --- a/Tests/adevcontainerTests/AllUnitTests.swift +++ b/Tests/adevcontainerTests/AllUnitTests.swift @@ -6669,15 +6669,42 @@ func makeSeedScriptFixture() throws -> (root: URL, home: URL, inheritedHome: URL try """ #!/bin/sh CONFIG="$HOME/git-config" - if [ "$1" = "config" ] && [ "$2" = "--global" ] && [ "$3" = "--add" ]; then - printf '%s=%s\\n' "$4" "$5" >> "$CONFIG" - elif [ "$1" = "config" ] && [ "$2" = "--global" ] && [ "$3" = "--get-all" ]; then - [ -f "$CONFIG" ] && awk -F= '$1 == "credential.helper" { print $2 }' "$CONFIG" - elif [ "$1" = "config" ] && [ "$2" = "--global" ] && [ "$3" = "--list" ]; then - [ -f "$CONFIG" ] && cat "$CONFIG" - elif [ "$1" = "credential" ] && [ "$2" = "approve" ]; then + touch "$CONFIG" + if [ "$1" = "config" ] && [ "$2" = "--global" ]; then + shift 2 + if [ "$1" = "--replace-all" ]; then + key="$2" + val="$3" + awk -F= -v k="$key" '$1 != k' "$CONFIG" > "$CONFIG.tmp" + printf '%s=%s\\n' "$key" "$val" >> "$CONFIG.tmp" + mv "$CONFIG.tmp" "$CONFIG" + exit 0 + fi + if [ "$1" = "--add" ]; then + printf '%s=%s\\n' "$2" "$3" >> "$CONFIG" + exit 0 + fi + if [ "$1" = "--unset-all" ]; then + key="$2" + awk -F= -v k="$key" '$1 != k' "$CONFIG" > "$CONFIG.tmp" + mv "$CONFIG.tmp" "$CONFIG" + exit 0 + fi + if [ "$1" = "--get-all" ]; then + awk -F= -v k="$2" '$1 == k { print substr($0, length(k)+2) }' "$CONFIG" + exit 0 + fi + if [ "$1" = "--list" ]; then + cat "$CONFIG" + exit 0 + fi + printf '%s=%s\\n' "$1" "$2" >> "$CONFIG" + exit 0 + fi + if [ "$1" = "credential" ] && [ "$2" = "approve" ]; then printf 'approve\\n' >> "$HOME/approve-calls" "$HOME/.adevcontainer/git-credential-adev" store + exit 0 fi exit 0 """.write(to: git, atomically: true, encoding: .utf8) @@ -6846,6 +6873,11 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void + "protocol=https\nhost=example.com\nusername=alice\npassword=c\n\n" ) try MiniTest.expect(!args.contains(where: { $0.contains("ADEV_SEED") })) + let script = args.last ?? "" + try MiniTest.expect(script.contains("git config --global --add credential.helper"), "non-Azure host still appends") + try MiniTest.expect(script.contains("credential.https://dev.azure.com.helper"), "Azure host is URL-scoped") + try MiniTest.expect(script.contains("credential.https://dev.azure.com.useHttpPath true")) + try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) }), ("guestSeedBindSSHOnlySkipsSilently", { let ws = try TestRepo.makeTempWorkspace(configJSON: #"{ "image": "alpine:3.20" }"#) @@ -6937,7 +6969,10 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void try MiniTest.expect(call.environment?.values.contains(password) != true) try MiniTest.expect(!args.contains("-e")) let script = args.last ?? "" - try MiniTest.expect(script.contains("git config --global --add credential.helper")) + 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("git config --global --replace-all credential.helper")) + try MiniTest.expect(!script.contains("git config --global --add credential.helper")) try MiniTest.expect(script.contains("git credential approve")) try MiniTest.expect(script.contains("printf 'protocol=%s\\nhost=%s\\nusername=%s\\npassword=%s\\n\\n'")) try MiniTest.expect(!script.contains(password)) @@ -7081,6 +7116,51 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void try MiniTest.expect(!args.contains(where: { $0.contains("ghp_seed") })) try MiniTest.expect(call.environment?.values.contains("ghp_seed") != true) try MiniTest.expect(args.contains("-u") && args.contains("root")) + let script = args.last ?? "" + try MiniTest.expect(script.contains("git config --global --add credential.helper")) + try MiniTest.expect(!script.contains("useHttpPath"), "GitHub seed must not set Azure useHttpPath") + try MiniTest.expect(!script.contains("credential.https://dev.azure.com.helper")) + try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) + }), + ("guestSeedInstallHTTPSCredentialDoesNotRefill", { + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.first == "exec" { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let creds = SeedMockCredential() + creds.defaultResult = .success(GitHTTPSCredentials(username: "should-not-fill", password: "nope")) + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let seed = GuestGitCredentialSeed( + credentials: creds, + runner: mock, + gitPathOverride: .some("/usr/bin/mock-git") + ) + try seed.installHTTPSCredential( + containerId: "ctr", + url: "https://dev.azure.com/plantsuite/PlantSuite/_git/PlantSuite", + credentials: GitHTTPSCredentials(username: "plantsuite", password: "already-filled"), + connectionUser: "vscode", + runtime: runtime + ) + try MiniTest.expect(creds.fillCalls.isEmpty, "prepared install must not fill again") + let execs = mock.calls.filter { $0.arguments.first == "exec" } + try MiniTest.expectEqual(execs.count, 1) + let script = execs[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("git config --global --replace-all credential.helper")) + try MiniTest.expect(!script.contains("git config --global --add credential.helper")) + try MiniTest.expect(!script.contains("already-filled"), "password stays off argv") + try MiniTest.expectEqual( + String(data: execs[0].stdinData ?? Data(), encoding: .utf8) ?? "", + "protocol=https\nhost=dev.azure.com\nusername=plantsuite\npassword=already-filled\n\n" + ) + try MiniTest.expect(!script.contains("_git"), "approve entries carry no path component") }), ("guestSeedVolumeMissingOrEmptyGitURLSkips", { let mock = MockProcessRunner() @@ -7175,11 +7255,29 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void let script = GuestGitCredentialSeed.seedScript() try MiniTest.expect(script.contains("set -e")) try MiniTest.expect(script.contains("git config --global --add credential.helper")) + try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper")) + try MiniTest.expect(!script.contains("useHttpPath")) try MiniTest.expect(script.contains("git credential approve")) try MiniTest.expect(script.contains("while IFS= read -r line")) try MiniTest.expect(!script.contains("ADEV_SEED"), "seed script must not read credential env") - try MiniTest.expect(!script.contains("https://"), "script must not embed any URL") + try MiniTest.expect(!script.contains("https://"), "default script must not embed a URL") try MiniTest.expect(!script.contains("ADEV_SEED_0_"), "script must not embed any env value") + 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.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, + 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(added < usePath, "product helper is configured before useHttpPath") + try MiniTest.expect(usePath < approve, "useHttpPath is set before approve") }), ("guestSeedScriptResolvesPasswdHomeBeforeCredentialSetup", { let script = GuestGitCredentialSeed.seedScript() @@ -7337,6 +7435,14 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void try MiniTest.expect(out.contains("username=Y"), "queried username wins over stored") try MiniTest.expect(!out.contains("username=X"), "stored username not emitted when the query carries one") try MiniTest.expect(out.contains("password=sec:ret@1"), "stored password returned on username-agnostic match") + // useHttpPath queries include path; the imported credential must still match. + let withPath = try runCredentialHelper( + helperPath: helperPath.path, action: "get", home: home, + input: "protocol=https\nhost=dev.azure.com\npath=plantsuite/PlantSuite/_git/PlantSuite\n\n" + ) + try MiniTest.expect(withPath.succeeded, "path-scoped get exits 0") + try MiniTest.expect(withPath.stdoutString.contains("password=sec:ret@1"), "path is ignored so useHttpPath queries match") + try MiniTest.expect(withPath.stdoutString.contains("username=X"), "stored username returned when the path query has none") }), ("guestSeedHelperGetExactMatchAndStoredUsernameFallback", { let helper = GuestGitCredentialSeed.helperScript() @@ -7524,7 +7630,9 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void }), ("guestSeedScriptConfigAppendsHelperAndDropsStore", { let script = GuestGitCredentialSeed.seedScript() - try MiniTest.expect(script.contains("git config --global --add credential.helper"), "helper appended via --add") + try MiniTest.expect(script.contains("git config --global --add credential.helper"), "non-Azure helper is appended") + try MiniTest.expect(!script.contains("git config --global --replace-all credential.helper"), "global helper list is not replaced") + try MiniTest.expect(!script.contains("useHttpPath"), "non-Azure script does not set Azure useHttpPath") try MiniTest.expect(script.contains("$HOME/.adevcontainer/git-credential-adev"), "absolute helper path") try MiniTest.expect(script.contains("chmod 700 \"$HELPER\""), "helper written 0700") try MiniTest.expect(script.contains("#!/bin/sh"), "helper has a shebang") @@ -7533,7 +7641,7 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void try MiniTest.expect(script.contains("git credential approve"), "approve loop kept") try MiniTest.expect(script.contains("while IFS= read -r line")) }), - ("guestSeedScriptPreservesPreExistingGlobalCredentialHelper", { + ("guestSeedScriptKeepsGlobalHelperAndScopesAzure", { let fixture = try makeSeedScriptFixture() defer { try? FileManager.default.removeItem(at: fixture.root) } let configPath = fixture.home.appendingPathComponent("git-config") @@ -7549,13 +7657,34 @@ nonisolated(unsafe) let guestGitCredentialSeedTests: [(String, () throws -> Void ) try MiniTest.expect(result.succeeded, "seed script configures the helper") let config = try String(contentsOf: configPath, encoding: .utf8) - guard let existing = config.range(of: "credential.helper=pre-existing")?.lowerBound, - let seeded = config.range(of: "credential.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev")?.lowerBound + let lines = config.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let existing = lines.firstIndex(of: "credential.helper=pre-existing"), + let seeded = lines.firstIndex(of: "credential.helper=\(fixture.home.path)/.adevcontainer/git-credential-adev") else { throw MiniTest.Failure(message: "global git config lost or omitted a credential helper") } - try MiniTest.expect(existing < seeded, "--add preserves the pre-existing helper order") - try MiniTest.expect(config.contains("core.askpass=keep"), "unrelated global config remains") + try MiniTest.expect(existing < seeded, "--add preserves the pre-existing helper") + try MiniTest.expect(!config.contains("useHttpPath")) + try MiniTest.expect(lines.contains("core.askpass=keep"), "unrelated global config remains") + + let azure = try FoundationProcessRunner().run( + executable: "/bin/sh", + arguments: ["-c", GuestGitCredentialSeed.seedScript(appendGlobalHelper: false, configureAzureHost: true)], + environment: fixture.environment, + currentDirectory: nil, + stdinData: Data("protocol=https\nhost=dev.azure.com\nusername=u\npassword=p\n\n".utf8) + ) + try MiniTest.expect(azure.succeeded, "Azure seed script configures the URL-scoped helper") + let azureLines = try String(contentsOf: configPath, encoding: .utf8) + .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.useHttpPath=true")) + try MiniTest.expect(!azureLines.contains("credential.helper="), "Azure seed does not empty the global helper") }), ("guestSeedHelperReadsTrailingBlockAndEraseKeepsIt", { let helper = GuestGitCredentialSeed.helperScript() diff --git a/Tests/adevcontainerTests/CloneInVolumeTests.swift b/Tests/adevcontainerTests/CloneInVolumeTests.swift index b28630b..30db61e 100644 --- a/Tests/adevcontainerTests/CloneInVolumeTests.swift +++ b/Tests/adevcontainerTests/CloneInVolumeTests.swift @@ -1616,7 +1616,10 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expectEqual(result.outcome, "success") let createCall = mock.calls.first { $0.arguments.first == "create" }! try MiniTest.expect(createCall.arguments.contains("--ssh")) - // SSH path does not call HTTPS credential fill + // SSH path does not call HTTPS credential fill or seed a guest helper. + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("git-credential-adev") == true + }, "SSH clone must not seed HTTPS credentials") try MiniTest.expect(git.fullCloneCalls.isEmpty) try MiniTest.expect(mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.contains("-c") @@ -1641,6 +1644,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ ) try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(creds.fillCalls.count, 1) + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("git-credential-adev") == true + }, "nil fill must not seed") try MiniTest.expect(git.fullCloneCalls.isEmpty) }), ("cloneHTTPSCloneFailWithoutCredsHintsCredentials", { @@ -2184,7 +2190,10 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ ) let configCalls = mock.calls.filter { $0.arguments.first == "exec" - && ($0.arguments.contains("--local") || $0.arguments.last?.contains("git config --global") == true) + && ( + $0.arguments.contains("--local") + || $0.arguments.last?.contains("git config --global --replace-all user.name") == true + ) } try MiniTest.expectEqual(configCalls.count, 3, "complete identity writes local and global pairs") let firstGlobal = configCalls.firstIndex { $0.arguments.last?.contains("--global") == true } @@ -2220,7 +2229,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ $0.arguments.first == "exec" && $0.arguments.contains("config") && $0.arguments.contains("--local") } try MiniTest.expect(configCalls.isEmpty) - try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git config --global") == true }) + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("git config --global --replace-all user.name") == true + }) }), ("cloneGlobalAuthorFailureWarnsAndRunsHook", { let restore = CloneGitFeatureTestSupport.installOverrides() @@ -2291,7 +2302,9 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ ) try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(warnings.count, 1) - try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git config --global") == true }) + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("git config --global --replace-all user.name") == true + }) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) try MiniTest.expect(mock.calls.contains { $0.arguments.last?.contains("local-author-hook") == true }) }), @@ -2750,11 +2763,12 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "delete" }) }), - ("cloneNeverRunsCredentialSeeding", { + ("cloneHTTPSNonAzureKeepsLocalStore", { let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } let git = MockGitClient() git.configJSONToWrite = #"{"name":"CloneApp","image":"alpine:3.20"}"# + let creds = MockGitCredential() let mock = MockProcessRunner() mock.handlers = CloneRuntimeMock.handlers() let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) @@ -2762,22 +2776,69 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ options: CloneOptions(gitURL: "https://github.com/org/clone-app.git", skipPull: true), runtime: runtime, git: git, - credentials: MockGitCredential(), + credentials: creds, localEnv: [:] ) try MiniTest.expectEqual(result.outcome, "success") - try MiniTest.expect( - !mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true }, - "clone must not run a seeding exec" - ) - try MiniTest.expect( - !mock.calls.contains { $0.arguments.contains("credential.helper") }, - "clone must not run the store+approve seeding script" + try MiniTest.expectEqual(creds.fillCalls.count, 1, "clone does not fill twice") + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("git-credential-adev") == true + }, "non-Azure clone must not install the product helper") + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("useHttpPath") == true + }) + try MiniTest.expect(!mock.calls.contains { + $0.arguments.last?.contains("git config --global --replace-all credential.helper") == true + }) + let cloneScripts = mock.calls.filter { + $0.arguments.first == "exec" && $0.arguments.last?.contains("git clone") == true + } + try MiniTest.expectEqual(cloneScripts.count, 1) + let script = cloneScripts[0].arguments.last ?? "" + try MiniTest.expect(script.contains("git -C \"$WS\" config credential.helper store")) + try MiniTest.expect(script.contains("git -C \"$WS\" credential approve")) + try MiniTest.expect(!script.contains("secret-token"), "password stays off the exec command") + }), + ("cloneHTTPSAzureUsesURLScopedHelper", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let git = MockGitClient() + git.configJSONToWrite = #"{"name":"CloneApp","image":"alpine:3.20"}"# + let creds = MockGitCredential() + let mock = MockProcessRunner() + mock.handlers = CloneRuntimeMock.handlers() + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let result = try CloneCommand.run( + options: CloneOptions( + gitURL: "https://dev.azure.com/plantsuite/PlantSuite/_git/PlantSuite", + skipPull: true + ), + runtime: runtime, + git: git, + credentials: creds, + localEnv: [:] ) - try MiniTest.expect( - !mock.calls.contains { $0.arguments.contains("--global") }, - "clone must not touch the guest global git config" + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(creds.fillCalls.count, 1, "Azure clone does not fill twice") + let seeds = mock.calls.filter { + $0.arguments.first == "exec" && $0.arguments.last?.contains("git-credential-adev") == true + } + try MiniTest.expectEqual(seeds.count, 1, "Azure clone seeds the URL-scoped helper once") + 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("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")) + try MiniTest.expect(!script.contains("secret-token")) + try MiniTest.expectEqual( + String(data: seeds[0].stdinData ?? Data(), encoding: .utf8) ?? "", + "protocol=https\nhost=dev.azure.com\nusername=user\npassword=secret-token\n\n" ) + let cloneScripts = mock.calls.filter { + $0.arguments.first == "exec" && $0.arguments.last?.contains("git clone") == true + } + try MiniTest.expect(!cloneScripts.contains { $0.arguments.last?.contains("credential.helper store") == true }) }), ("cloneInDirectoryDockerfileBuilds", { let restore = CloneGitFeatureTestSupport.installOverrides() From a709910068e8f665fa667cf583353fb690a7df97 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 2 Oct 2026 00:08:35 -0300 Subject: [PATCH 2/3] docs: update test count badge adevcontainerTests passed 1163. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 643d8be..1da33fa 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-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). From 0bdf096651771456fb4e996d42c34f933d6ea25b Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 2 Oct 2026 00:15:18 -0300 Subject: [PATCH 3/3] docs: record Azure-scoped guest git credentials Non-Azure HTTPS is unchanged. Only dev.azure.com gets a URL-scoped helper and useHttpPath, so a pathless query is not proxied to host GCM. --- wiki/architecture.md | 2 +- wiki/conventions/cli-runtime-boundary.md | 4 ++-- wiki/domain/devcontainer-apple-gaps.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wiki/architecture.md b/wiki/architecture.md index 8c118d6..bd0d00f 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 (VS Code clone-in-volume analogue): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot → guest `credential.helper store`; 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 (VS Code clone-in-volume analogue): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot. Non-Azure unchanged — clone: local `credential.helper store` + pathless approve; up/rebuild: append-only global product helper, inherited helpers kept. `dev.azure.com` only: empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev`, and `credential.https://dev.azure.com.useHttpPath true`. Product helper matches protocol+host and ignores path. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM). No GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | | `rebuild [--name]` | **Forced-rebuild** path (realized in domain specs; archive [`20260810-rebuild`](../specs/changes/archive/20260810-rebuild/)): managed selection (`--name` / auto-single / picker); read **current** stamped `devcontainer.json` before any delete and capture the existing workspace-local Git author identity as the sole source of truth (bind: host workspace; volume: old container workspace); after config/host/Features succeed → container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `*-ws` (rebuild never deletes `*-ws`) and `${devcontainerId}` volumes; after replacement preparation, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; no host config or other container users changed; missing/incomplete → no invented value. User-literal sources stay as written (data preserved, never re-clone). Foreign occupant of the new name: do not delete selected; TTY Y/n + new name. Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: a name-indexed resume is published before delete, and `rebuild --name` opens that session when the container is gone and no helper exists, reusing the stamped `*-ws` without creating a blank workspace volume. A listed marked helper still wins. | | `list [--json]` | Managed containers only (`devcontainer.managed=adevcontainer`) | | `start [--name]` | Start a managed stopped container via `--name` or interactive picker. **Real start** (stopped→running, bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt → CLI-attach `postAttachCommand`. **Already-running start:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. Optional `--vscode` is **open only**. postAttach loads config from labels (bind: host `local_folder`+`config_file`; volume: in-container config path) and merges feature postAttach from image `devcontainer.metadata` (load errors → treat absent, do not fail start). Start failure recovery delegates to `rebuild --name` — does **not** re-run start or open an editor | diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 15d5636..6ff2ddc 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -228,7 +228,7 @@ Contract: [`specs/core.md`](../../specs/core.md) (workspace parents + read-only ### Git credential seeding (create paths) -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, appends it with `git config --global --add credential.helper `, and feeds one pathless entry per unique (protocol, host, username) to `git credential approve` over stdin. The helper's private store is `$HOME/.adevcontainer/git-credentials` mode 0600; `get` matches protocol+host while ignoring the queried username and returns that username (or the stored username when the query has none) with the stored password, so sibling repos on the same host are covered. 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 — its populate uses one-shot auth followed by guest `credential.helper store` + `git credential approve`; clone does not invoke this seeding path. +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`. **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`). 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 `credential.helper store` + `git credential approve` once. 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) → 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. - **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. diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index 16698ed..b791ff2 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 + 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, and it is appended with `git config --global --add credential.helper`; unique pathless entries are approved via stdin, and helper `get` matches protocol+host while ignoring the queried username and returns that username (or the stored username when the query has none) with the stored password, covering sibling repos. 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 → guest `credential.helper store` + approve. No GCM-in-guest; no host `~/.git-credentials` mount; no PAT CLI primary UX | +| Auth for git | **HTTPS:** bind-mode create paths (`up` fresh create; `rebuild` bind) seed a username-agnostic POSIX-sh helper in the effective connection user's HOME before create-path hooks — HOME is resolved from the effective user, the helper is 0700, its private store is 0600. Non-Azure keeps an append-only global `credential.helper` (inherited helpers kept) and pathless approve. `dev.azure.com` only gets a URL-scoped override — empty `credential.https://dev.azure.com.helper`, then `$HOME/.adevcontainer/git-credential-adev` — and `credential.https://dev.azure.com.useHttpPath true`. Helper `get` matches protocol+host and ignores path and the queried username, returning that username (or the stored username when the query has none) with the stored password, covering sibling repos. A pathless query proxied to host Git Credential Manager fatals with "Cannot determine the organization name" because the org is in the URL path; do not copy host `credential.helper` (`osxkeychain` / GCM) into the guest. Host acquisition uses `git credential fill` (`ADEVCONTAINER_GIT_TOKEN` / `gh auth token` fallbacks); seeded credential material enters the guest via stdin only, never argv or env. Silent skip: host git missing / no repo or remotes / fill nil (public stays anonymous). Soft-fail warn. **Not seeded:** SSH remotes; non-create paths (`up` reuse, `up` start-stopped, bare `start`) | **SSH:** `SSH_AUTH_SOCK` + `create --ssh`. **HTTPS:** clone's separate populate uses host `git credential fill` one-shot. Non-Azure: local `credential.helper store` + pathless approve. `dev.azure.com`: the URL-scoped helper and `useHttpPath` in the bind cell, not pathless `credential.helper store`. No GCM-in-guest; no host `~/.git-credentials` mount; no host `credential.helper` copy; no PAT CLI primary UX | | Populate | N/A (host tree) | **In-container full `git clone`** + verify `.git` (host = config-only sparse/shallow only; no host full+tar happy path) | 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/).