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
48 changes: 15 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,39 +125,21 @@ The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` command

### Automatic Git credentials

For discovered HTTP(S) Git destinations, lifecycle commands connect the resolved
container user to the existing host credential provider before hooks; clone does
so before its single in-container full clone. Credentials travel on private
exec/socket channels, not guest token stores, argv or product-created environment
variables. Git/GCM interaction on the host is disabled and acquisition is bounded.

The same installed CLI starts a per-user broker process on demand, without sudo,
before the first credential-dependent lifecycle work. It runs in its own session,
survives the invoking CLI, and exits after five seconds without demand. Nothing is
installed and nothing restarts it: a crash is recovered by the next `up`, `start`,
clone, or rebuild. The next activation reuses a broker from the same executable and
gracefully replaces one from an older build. Registrations on disk contain
identity/destinations, not credentials; explicit host token overrides live only in
memory and require re-registration after the broker exits. No separate guest package,
Python, socat, GCM, translator or compiler is installed at runtime.

Authorization binds the discovered scheme/hostname/port, preserving sibling paths.
Legacy HTTP is retained only when discovered; HTTPS never authorizes HTTP and no
unsafe-HTTP provider option is enabled. Azure retains scheme-specific helper and
useHttpPath configuration and receives the actual queried repository path.
Earlier user/legacy helpers remain in order and may answer first; those answers
are outside the product helper's freshness guarantee. Unmarked legacy clone stores
are not deleted. The identified old product-owned seed store is migrated away.

`start` and `up` on an already-running container repair the automatic
connection without fetch/pull/push or workspace writes. They do not clear
rejection suppression. To clear that finite block, stop the container and
start it again. A provider miss remains a miss. If login is needed, authenticate
on the host and retry. An unchanged token does not prove OAuth renewal.
Replacing or moving the executable is detected by identity. The next ordinary
lifecycle invocation compares the running broker's executable device/inode and
build identity with the invoking executable, retires a mismatched owner, and
starts a successor from the current executable.
Use your existing host Git login for HTTP(S) repositories inside your dev
container, without a separate container login. Setup is automatic during `up`,
`clone`, `start`, and `rebuild`, before the in-container clone or lifecycle hooks.

Keep your host login valid. If authentication stops working, sign in again on
the host and run `adevcontainer up` or `adevcontainer start` to repair the
connection—even when the container is already running.

For local SSH Git remotes, fresh `up` and bind-mode `rebuild` automatically forward
your host SSH agent when `SSH_AUTH_SOCK` points to a socket; HTTPS-only workspaces
do not automatically expose it. If forwarding is unavailable in an existing
container, run `adevcontainer rebuild --name <name>` to apply create-time settings.

See the [Git credential implementation notes](wiki/conventions/cli-runtime-boundary.md#automatic-git-credentials)
for technical details and troubleshooting.

### Source builds

Expand Down
11 changes: 9 additions & 2 deletions Sources/ADevContainerLib/Commands/RebuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,12 @@ public enum RebuildCommand {

// ═══════════════════════════ PHASE B (destructive create path) ═══════════════════════════

let ssh = isVolumeMode ? SSHForwardingPolicy.Decision() : SSHForwardingPolicy.evaluate(
workspace: stampedLocalFolder(labels), environment: localEnv,
explicitSSH: effectiveConfig.runArgs.contains(.ssh)
)
if let warning = ssh.warning { StatusPrinter.warning(warning) }

// Publish the session under the container name BEFORE delete. A process exit after
// the container is gone must still be resumable by `rebuild --name` without a helper.
if isVolumeMode, let session = recoveryContext?.session ?? resumedSession {
Expand Down Expand Up @@ -742,7 +748,8 @@ public enum RebuildCommand {
labels: newLabels,
configHash: configHash,
workspacePath: stampedLocalFolder(labels),
platform: platform
platform: platform,
enableSSHForward: ssh.automatic
)
}

Expand Down Expand Up @@ -785,7 +792,7 @@ public enum RebuildCommand {
StatusPrinter.status("Creating container", item: replacementName)
let id: String
do {
id = try runtime.create(request: request, ensureVolumes: false)
id = try runtime.create(request: request, ensureVolumes: false, hostEnvironment: ssh.automatic ? localEnv : nil)
} catch {
if allowRecovery, let recovery = recoveryContext {
return try RecoveryOrchestrator.recover(
Expand Down
2 changes: 2 additions & 0 deletions Sources/ADevContainerLib/Commands/StartCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public enum StartCommand {
try preflightStrictCompatibility(info: info, runtime: runtime, localEnv: localEnv)

if info.isRunning {
SSHForwardingPolicy.warnForExisting(labels: info.labels, name: info.name, environment: localEnv)
StatusPrinter.status("Container already running", item: info.id)
GitCredentialLifecycle.setupSoft(id: info.id, labels: info.labels, runtime: runtime, credentials: credentials)
try openAndPostAttach(
Expand All @@ -69,6 +70,7 @@ public enum StartCommand {
hostWorkspace: hostWorkspace
)

SSHForwardingPolicy.warnForExisting(labels: info.labels, name: info.name, environment: localEnv)
StatusPrinter.status("Starting container", item: info.id)
do {
try runtime.start(nameOrId: info.id)
Expand Down
12 changes: 10 additions & 2 deletions Sources/ADevContainerLib/Commands/UpCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ public enum UpCommand {
hint: "Run '\(CommandSurface.commandPrefix) rebuild' (managed selection: --name or auto) to force-rebuild from current config"
)
} else if existing.isRunning {
SSHForwardingPolicy.warnForExisting(labels: existing.labels, name: existing.name, environment: localEnv)
// Reuse running: no feature fetch/build; settings+extensions repair on marker drift; postAttach gated after open.
StatusPrinter.status("Reusing running container", item: existing.name)
GitCredentialLifecycle.setupSoft(id: existing.id, labels: existing.labels, runtime: runtime, credentials: credentials)
Expand All @@ -249,6 +250,7 @@ public enum UpCommand {
)
} else {
// Start stopped: no rebuild (features already baked on create).
SSHForwardingPolicy.warnForExisting(labels: existing.labels, name: existing.name, environment: localEnv)
StatusPrinter.status("Starting container")
do {
try runtime.start(nameOrId: existing.id)
Expand Down Expand Up @@ -446,19 +448,25 @@ public enum UpCommand {
labels[ContainerIdentity.labelRemoteUser] = connectionUser
labels[ContainerIdentity.labelWorkspaceFolder] = effectiveConfig.workspaceFolder

let ssh = SSHForwardingPolicy.evaluate(
workspace: resolved.workspacePath, environment: localEnv,
explicitSSH: effectiveConfig.runArgs.contains(.ssh)
)
if let warning = ssh.warning { StatusPrinter.warning(warning) }
let request = CreateRequest.from(
resolved: effectiveConfig,
identityName: resolved.containerName,
labels: labels,
configHash: resolved.configHash,
workspacePath: resolved.workspacePath,
platform: platform
platform: platform,
enableSSHForward: ssh.automatic
)

StatusPrinter.status("Creating container", item: resolved.containerName)
let id: String
do {
id = try runtime.create(request: request, initializeConfigVolumes: true)
id = try runtime.create(request: request, initializeConfigVolumes: true, hostEnvironment: ssh.automatic ? localEnv : nil)
} catch {
throw BringUpRecovery.eligible(error)
}
Expand Down
76 changes: 76 additions & 0 deletions Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Darwin
import Foundation

/// Bind-only, host-local eligibility. Socket metadata is not an authentication probe.
enum SSHForwardingPolicy {
static let label = "devcontainer.ssh_forwarded"
struct Decision {
var automatic = false
var warning: String?
}

static func evaluate(
workspace: String,
environment: [String: String],
explicitSSH: Bool,
runner: (any ProcessRunning)? = nil,
gitPath: String? = nil
) -> Decision {
// Explicit intent retains the existing inherited runtime environment/behavior.
guard !explicitSSH else { return Decision() }
let git = gitPath ?? (environment["PATH"] ?? "/usr/bin:/bin")
.split(separator: ":").map { String($0) + "/git" }
.first { $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0) }
guard let git else { return Decision() }
let selected = runner ?? LifecycleRunner.hostProcessRunnerOverride ?? CredentialProviderProcessRunner(timeout: 3)
var discoveryEnvironment = HostGitCredential.strippingCredentialTraces(environment)
discoveryEnvironment.removeValue(forKey: "ADEVCONTAINER_GIT_TOKEN")
discoveryEnvironment["GIT_TERMINAL_PROMPT"] = "0"
// No shell, transport, credential helper or raw diagnostic output. Both URL directions matter.
guard let result = try? selected.run(
executable: git, arguments: ["-C", workspace, "remote", "-v"],
environment: discoveryEnvironment, currentDirectory: nil, stdinData: nil
), result.succeeded else { return Decision() }
// Reuse the existing parser, changing only the terminal direction marker, never URL bytes.
let bothDirections = result.stdoutString.split(whereSeparator: \.isNewline).map { line in
line.hasSuffix("(push)") ? String(line.dropLast(6)) + "(fetch)" : String(line)
}.joined(separator: "\n")
let urls = GitCredentialDiscovery.uniqueFetchURLs(from: bothDirections)
guard urls.contains(where: isSSH) else { return Decision() }
guard hasSocket(environment) else {
return Decision(warning: "SSH remote detected but SSH_AUTH_SOCK does not identify a host agent socket; continuing without automatic SSH forwarding. Expose your host agent via SSH_AUTH_SOCK, then run \(CommandSurface.commandPrefix) rebuild if the container was already created.")
}
return Decision(automatic: true)
}

private static func hasSocket(_ environment: [String: String]) -> Bool {
let path = environment["SSH_AUTH_SOCK"] ?? ""
var metadata = stat()
return !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !path.utf8.contains(0) && stat(path, &metadata) == 0
&& metadata.st_mode & S_IFMT == S_IFSOCK
}

private static func isSSH(_ url: String) -> Bool {
// Exclude local paths before using clone's classifier, which intentionally stays unchanged.
guard !url.hasPrefix("/"), !url.hasPrefix("./"), !url.hasPrefix("../"), !url.hasPrefix("~"),
!url.contains(where: { $0.isWhitespace || $0.isNewline }) else { return false }
// Git's transport::address selects a remote helper, not SCP. Colons
// inside a bracketed IPv6 host (or later in an SCP path) are not this separator.
if let colon = url.firstIndex(of: ":"),
url[url.index(after: colon)...].hasPrefix(":"), !url[..<colon].contains("[") { return false }
if !url.contains("://"), let colon = url.firstIndex(of: ":"), url[..<colon].contains("/") { return false }
if GitURLClassifier.kind(of: url) == .ssh { return true }
guard !url.contains("://"), let colon = url.firstIndex(of: ":"),
colon != url.startIndex, url.index(after: colon) != url.endIndex else { return false }
let host = url[..<colon]
return !host.contains("/") && !host.contains("@")
}

static func warnForExisting(labels: [String: String], name: String, environment: [String: String]) {
guard labels[ContainerIdentity.labelWorkspaceMode] == ContainerIdentity.workspaceModeBind,
labels[label] != "true", hasSocket(environment), let workspace = labels[ContainerIdentity.labelLocalFolder],
evaluate(workspace: workspace, environment: environment, explicitSSH: false).automatic else { return }
StatusPrinter.warning("SSH forwarding is fixed at container creation; if unavailable in this container, run '\(CommandSurface.commandPrefix) rebuild --name \(name)' to apply it. Existing forwarding has not been changed.")
}
}
17 changes: 12 additions & 5 deletions Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,8 @@ public struct AppleContainerRuntime: Sendable {
request: CreateRequest,
ensureVolumes: Bool = true,
initializeConfigVolumes: Bool = false,
workspaceVolumeEnsured: ((Bool) -> Void)? = nil
workspaceVolumeEnsured: ((Bool) -> Void)? = nil,
hostEnvironment: [String: String]? = nil
) throws -> String {
// Ensure workspace named volume (clone / volume-mode).
// `true` means this call created it; `false` means it already existed and was reused.
Expand All @@ -853,7 +854,13 @@ public struct AppleContainerRuntime: Sendable {
}

let args = request.createArguments()
let result = try invoke(args, streamStderr: true)
// Automatic SSH selection is host-only. Capture diagnostics before redacting the
// endpoint (including races where a selected socket disappears during create).
var result = try invoke(args, streamStderr: hostEnvironment == nil, environment: hostEnvironment)
if let socket = hostEnvironment?["SSH_AUTH_SOCK"], !socket.isEmpty {
result.stdout = Data(result.stdoutString.replacingOccurrences(of: socket, with: "<SSH_AUTH_SOCK>").utf8)
result.stderr = Data(result.stderrString.replacingOccurrences(of: socket, with: "<SSH_AUTH_SOCK>").utf8)
}
try ensureSuccess(result, action: "create")
let id = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
return id.isEmpty ? request.name : id
Expand Down Expand Up @@ -1664,12 +1671,12 @@ printf 'RECOVERY_APPLIED:%s\n' "$actual"
}

@discardableResult
public func invoke(_ arguments: [String], streamStderr: Bool = false) throws -> ProcessResult {
public func invoke(_ arguments: [String], streamStderr: Bool = false, environment: [String: String]? = nil) throws -> ProcessResult {
if streamStderr, let streaming = runner as? any StreamTeeingProcessRunning {
return try streaming.run(
executable: executablePath,
arguments: arguments,
environment: nil,
environment: environment,
currentDirectory: nil,
stdinData: nil,
streamStderr: true,
Expand All @@ -1679,7 +1686,7 @@ printf 'RECOVERY_APPLIED:%s\n' "$actual"
return try runner.run(
executable: executablePath,
arguments: arguments,
environment: nil,
environment: environment,
currentDirectory: nil
)
}
Expand Down
11 changes: 8 additions & 3 deletions Sources/ADevContainerLib/Runtime/CreateRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ public struct CreateRequest: Equatable, Sendable {
configHash: String,
workspacePath: String,
platform: String? = ContainerPlatform.defaultLinuxPlatform,
devcontainerId: String? = nil
devcontainerId: String? = nil,
enableSSHForward: Bool = false
) -> CreateRequest {
let expanded: ResolvedDevContainerConfig
if let stem = devcontainerId, !stem.isEmpty {
Expand All @@ -254,10 +255,14 @@ public struct CreateRequest: Equatable, Sendable {
expanded = resolved
}
let (memoryLimit, cpuLimit) = mergeMemoryCpuLimits(from: expanded)
var runArgs = expanded.runArgs
if enableSSHForward, !runArgs.contains(.ssh) { runArgs.append(.ssh) }
var bindLabels = labelsWithExpandedConfigVolumes(labels, mounts: expanded.mounts)
bindLabels[SSHForwardingPolicy.label] = runArgs.contains(.ssh) ? "true" : "false"
return CreateRequest(
name: identityName,
image: expanded.image,
labels: labelsWithExpandedConfigVolumes(labels, mounts: expanded.mounts),
labels: bindLabels,
workspaceBindHost: (workspacePath as NSString).standardizingPath,
workspaceBindTarget: expanded.workspaceFolder,
workspaceMountMode: .bind,
Expand All @@ -268,7 +273,7 @@ public struct CreateRequest: Equatable, Sendable {
mounts: expanded.mounts,
publishPorts: expanded.forwardPorts,
portsAttributes: expanded.portsAttributes,
runArgs: expanded.runArgs,
runArgs: runArgs,
memoryLimit: memoryLimit,
cpuLimit: cpuLimit,
platform: platform,
Expand Down
6 changes: 4 additions & 2 deletions Tests/adevcontainerTests/AllCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [
"mounts": [
"source=calendars-cargo-data,target=/usr/local/cargo/,type=volume"
],
"runArgs": ["--read-only"],
"runArgs": ["--read-only", "--ssh"],
"postCreateCommand": "test -x /usr/local/cargo/bin/cargo && cargo fetch --locked"
}
""")
Expand Down Expand Up @@ -203,6 +203,8 @@ nonisolated(unsafe) let upTests: [(String, () throws -> Void)] = [
let mainCreate = createCalls.first { $0.contains(resolved.containerName) }!
try MiniTest.expect(!initializerCreate.contains("--read-only"), "initializer needs a writable rootfs and volume")
try MiniTest.expect(mainCreate.contains("--read-only"), "main container keeps the requested read-only rootfs")
try MiniTest.expect(mainCreate.contains("--ssh"), "main container keeps explicit forwarding")
try MiniTest.expect(!initializerCreate.contains("--ssh"), "image copy-up helper must not inherit SSH exposure")
let initExec = mock.calls.firstIndex { $0.arguments.contains("volume-init-id") && $0.arguments.first == "exec" }
let postCreateExec = mock.calls.firstIndex { $0.arguments.last?.contains("cargo fetch --locked") == true }
try MiniTest.expect(initExec != nil && postCreateExec != nil && initExec! < postCreateExec!, "volume initialization precedes postCreateCommand")
Expand Down Expand Up @@ -1251,7 +1253,7 @@ nonisolated(unsafe) let credentialRefreshCommandTests: [(String, () throws -> Vo
)
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(host.calls.allSatisfy { $0.arguments.suffix(2) == ["remote", "-v"] }, "only read-only SSH eligibility discovery is allowed")
try MiniTest.expectEqual(connection.events, ["setup"])
try MiniTest.expect(!mock.calls.contains { $0.arguments.last?.contains("git-credential-adev") == true })
}),
Expand Down
Loading
Loading