From 2d3018b55b02a294f12a118c8ef709da97568916 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Thu, 22 Oct 2026 10:45:38 -0300 Subject: [PATCH] fix: forward host SSH agent for eligible bind workspaces Share bind up/rebuild SSH eligibility, preserve explicit forwarding and existing-container settings, and add synthetic policy and lifecycle coverage. Include pending Git credential documentation, broker/guardian notes, realized requirements, and the archived verification and incident record. --- README.md | 48 ++--- .../Commands/RebuildCommand.swift | 11 +- .../Commands/StartCommand.swift | 2 + .../ADevContainerLib/Commands/UpCommand.swift | 12 +- .../Git/SSHForwardingPolicy.swift | 76 ++++++++ .../Runtime/AppleContainerRuntime.swift | 17 +- .../Runtime/CreateRequest.swift | 11 +- .../adevcontainerTests/AllCommandTests.swift | 6 +- .../AutomaticSSHForwardingCommandTests.swift | 170 ++++++++++++++++++ .../CloneInVolumeTests.swift | 21 ++- .../RebuildCommandPhaseTests.swift | 5 +- .../SSHForwardingPolicyTests.swift | 132 ++++++++++++++ .../adevcontainerTests/VSCodeOpenTests.swift | 10 +- Tests/adevcontainerTests/main.swift | 6 +- .../proposal.md | 26 +++ .../spec.md | 58 ++++++ .../tasks.md | 127 +++++++++++++ specs/core.md | 55 ++++++ wiki/architecture.md | 2 +- wiki/conventions/cli-runtime-boundary.md | 34 +++- 20 files changed, 766 insertions(+), 63 deletions(-) create mode 100644 Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift create mode 100644 Tests/adevcontainerTests/AutomaticSSHForwardingCommandTests.swift create mode 100644 Tests/adevcontainerTests/SSHForwardingPolicyTests.swift create mode 100644 specs/changes/archive/20261022-automatic-ssh-forwarding-up/proposal.md create mode 100644 specs/changes/archive/20261022-automatic-ssh-forwarding-up/spec.md create mode 100644 specs/changes/archive/20261022-automatic-ssh-forwarding-up/tasks.md diff --git a/README.md b/README.md index e4dd343..8ef8b4a 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/Sources/ADevContainerLib/Commands/RebuildCommand.swift b/Sources/ADevContainerLib/Commands/RebuildCommand.swift index 81901e1..60d6381 100644 --- a/Sources/ADevContainerLib/Commands/RebuildCommand.swift +++ b/Sources/ADevContainerLib/Commands/RebuildCommand.swift @@ -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 { @@ -742,7 +748,8 @@ public enum RebuildCommand { labels: newLabels, configHash: configHash, workspacePath: stampedLocalFolder(labels), - platform: platform + platform: platform, + enableSSHForward: ssh.automatic ) } @@ -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( diff --git a/Sources/ADevContainerLib/Commands/StartCommand.swift b/Sources/ADevContainerLib/Commands/StartCommand.swift index d1ed4ec..3cda6e4 100644 --- a/Sources/ADevContainerLib/Commands/StartCommand.swift +++ b/Sources/ADevContainerLib/Commands/StartCommand.swift @@ -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( @@ -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) diff --git a/Sources/ADevContainerLib/Commands/UpCommand.swift b/Sources/ADevContainerLib/Commands/UpCommand.swift index a166785..d887044 100644 --- a/Sources/ADevContainerLib/Commands/UpCommand.swift +++ b/Sources/ADevContainerLib/Commands/UpCommand.swift @@ -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) @@ -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) @@ -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) } diff --git a/Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift b/Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift new file mode 100644 index 0000000..c6515c3 --- /dev/null +++ b/Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift @@ -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[.. 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. @@ -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: "").utf8) + result.stderr = Data(result.stderrString.replacingOccurrences(of: socket, with: "").utf8) + } try ensureSuccess(result, action: "create") let id = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) return id.isEmpty ? request.name : id @@ -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, @@ -1679,7 +1686,7 @@ printf 'RECOVERY_APPLIED:%s\n' "$actual" return try runner.run( executable: executablePath, arguments: arguments, - environment: nil, + environment: environment, currentDirectory: nil ) } diff --git a/Sources/ADevContainerLib/Runtime/CreateRequest.swift b/Sources/ADevContainerLib/Runtime/CreateRequest.swift index fe3a53b..634d719 100644 --- a/Sources/ADevContainerLib/Runtime/CreateRequest.swift +++ b/Sources/ADevContainerLib/Runtime/CreateRequest.swift @@ -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 { @@ -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, @@ -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, diff --git a/Tests/adevcontainerTests/AllCommandTests.swift b/Tests/adevcontainerTests/AllCommandTests.swift index 1299279..57f840e 100644 --- a/Tests/adevcontainerTests/AllCommandTests.swift +++ b/Tests/adevcontainerTests/AllCommandTests.swift @@ -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" } """) @@ -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") @@ -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 }) }), diff --git a/Tests/adevcontainerTests/AutomaticSSHForwardingCommandTests.swift b/Tests/adevcontainerTests/AutomaticSSHForwardingCommandTests.swift new file mode 100644 index 0000000..11de910 --- /dev/null +++ b/Tests/adevcontainerTests/AutomaticSSHForwardingCommandTests.swift @@ -0,0 +1,170 @@ +import Darwin +import Foundation +@testable import ADevContainerLib + +private struct SSHPlainFoundationRunner: ProcessRunning { + func run(executable: String, arguments: [String], environment: [String: String]?, currentDirectory: String?, stdinData: Data?) throws -> ProcessResult { + try FoundationProcessRunner().run(executable: executable, arguments: arguments, environment: environment, currentDirectory: currentDirectory, stdinData: stdinData) + } +} + +@MainActor let automaticSSHForwardingCommandTests: [(String, () throws -> Void)] = [ + ("ssh.commands.S01S02S03S04S06.freshCreateMatrix", { + let socket = try SSHSocketFixture() + let discovery = SSHDiscoveryRunner() + let prior = LifecycleRunner.hostProcessRunnerOverride + LifecycleRunner.hostProcessRunnerOverride = discovery + let port = AutomaticCredentialCommandPort() + GitCredentialLifecycle.setTestConnection(port) + let oldWarning = StatusPrinter.onWarning + defer { LifecycleRunner.hostProcessRunnerOverride = prior; GitCredentialLifecycle.setTestConnection(nil); StatusPrinter.onWarning = oldWarning } + for transport in ["ssh", "https", "mixed"] { + for agent in [false, true] { + for explicit in [false, true] { + let json = "{\"image\":\"alpine:3.20\",\"remoteUser\":\"root\",\"runArgs\":\(explicit ? "[\"--ssh\",\"--ssh\"]" : "[]"),\"postCreateCommand\":\"echo synthetic-hook\"}" + let ws = try TestRepo.makeTempWorkspace(configJSON: json) + defer { try? FileManager.default.removeItem(at: ws) } + let env = agent ? ["SSH_AUTH_SOCK": socket.path] : [:] + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: env) + discovery.output = transport == "ssh" ? "origin\tgit@example.invalid:repo (fetch)\n" : "origin\thttps://user:secret@example.invalid/repo (fetch)\n" + if transport == "mixed" { discovery.output += "origin\tgit@example.invalid:repo (push)\n" } + var warnings: [String] = [] + var warningsAtHook: [String] = [] + let mock = MockProcessRunner() + mock.handlers = [MockProcessRunner.imageInspectHandler(baseUser: nil), { args in + if args.first == "list" { return .init(exitCode: 0, stdout: Data("[]".utf8), stderr: Data()) } + if args.first == "create" { return .init(exitCode: 0, stdout: Data(resolved.containerName.utf8), stderr: Data()) } + if args.first == "exec", args.last?.contains("synthetic-hook") == true { warningsAtHook = warnings } + return nil + }] + StatusPrinter.onWarning = { warnings.append($0) } + let runtime = AppleContainerRuntime(executablePath: "/synthetic/container", runner: mock) + var stdout = "" + fflush(nil) + try withCapturedStdout({ + _ = try UpCommand.run(options: .init(workspacePath: ws.path, jsonOutput: true, skipPull: true), runtime: runtime, localEnv: env, isTTY: false) + }, capture: &stdout) + let result = try JSONSerialization.jsonObject(with: Data(stdout.utf8)) as? [String: Any] + try MiniTest.expectEqual(result?["outcome"] as? String, "success") + try MiniTest.expect(!stdout.contains(socket.path) && !stdout.contains("secret")) + let call = mock.calls.first { $0.arguments.first == "create" }! + let automatic = transport != "https" && agent && !explicit + let enabled = explicit || automatic + try MiniTest.expectEqual(call.arguments.filter { $0 == "--ssh" }.count, enabled ? 1 : 0) + try MiniTest.expect(call.arguments.contains("devcontainer.ssh_forwarded=\(enabled)")) + try MiniTest.expectEqual(call.environment, automatic ? env : nil) + try MiniTest.expect(!call.arguments.joined().contains(socket.path)) + try MiniTest.expect(!call.arguments.joined().contains("secret")) + let sshWarnings = warnings.filter { $0.contains("SSH_AUTH_SOCK") } + try MiniTest.expectEqual(sshWarnings.count, transport != "https" && !agent && !explicit ? 1 : 0) + try MiniTest.expectEqual(warningsAtHook.filter { $0.contains("SSH_AUTH_SOCK") }.count, sshWarnings.count) + try MiniTest.expectEqual(try ConfigResolver.resolve(workspacePath: ws.path, localEnv: env).configHash, resolved.configHash) + } + } + } + }), + ("ssh.commands.S07.bindRebuildParity", { + let socket = try SSHSocketFixture() + let discovery = SSHDiscoveryRunner() + let prior = LifecycleRunner.hostProcessRunnerOverride + LifecycleRunner.hostProcessRunnerOverride = discovery + GitCredentialLifecycle.setTestConnection(AutomaticCredentialCommandPort()) + defer { LifecycleRunner.hostProcessRunnerOverride = prior; GitCredentialLifecycle.setTestConnection(nil) } + for hasSSH in [false, true] { + for agent in [false, true] { + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"root"}"#) + defer { try? FileManager.default.removeItem(at: ws) } + let s = RebuildScenario() + s.containers = [RebuildScenario.container(id: "old-id", labels: s.bindLabels(localFolder: ws.path, configFile: ws.appendingPathComponent(".devcontainer/devcontainer.json").path))] + s.install() + discovery.output = "origin\t\(hasSSH ? "git@example.invalid:repo" : "https://example.invalid/repo") (fetch)\n" + let env = agent ? ["SSH_AUTH_SOCK": socket.path] : [:] + _ = try RebuildCommand.run(options: .init(name: "old-id", skipPull: true), runtime: s.runtime, localEnv: env, isTTY: false) + let call = s.mock.calls.first { $0.arguments.first == "create" }! + try MiniTest.expectEqual(call.arguments.filter { $0 == "--ssh" }.count, hasSSH && agent ? 1 : 0) + try MiniTest.expectEqual(call.environment, hasSSH && agent ? env : nil) + try MiniTest.expect(!s.mock.calls.contains { $0.arguments.starts(with: ["volume", "delete"]) }) + try MiniTest.expect(!call.arguments.joined().contains(socket.path)) + } + } + }), + ("ssh.commands.S08S09.immutableReuseAndStart", { + let socket = try SSHSocketFixture() + let discovery = SSHDiscoveryRunner() + let prior = LifecycleRunner.hostProcessRunnerOverride + let oldWarning = StatusPrinter.onWarning + let oldPrefix = CommandSurface.commandPrefix + CommandSurface.commandPrefix = "container dev" + LifecycleRunner.hostProcessRunnerOverride = discovery + GitCredentialLifecycle.setTestConnection(AutomaticCredentialCommandPort()) + defer { LifecycleRunner.hostProcessRunnerOverride = prior; StatusPrinter.onWarning = oldWarning; CommandSurface.commandPrefix = oldPrefix; GitCredentialLifecycle.setTestConnection(nil) } + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"root"}"#) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + for running in [false, true] { + for marker in [nil, "false", "true"] as [String?] { + for start in [false, true] { + for (agent, hasSSH) in [(false, false), (false, true), (true, false), (true, true)] { + discovery.output = hasSSH ? "origin\tgit@example.invalid:repo (fetch)\n" : "origin\thttps://example.invalid/repo (fetch)\n" + var labels = resolved.labels + labels[SSHForwardingPolicy.label] = marker + let entry = MockProcessRunner.containerListJSON(id: resolved.containerName, state: running ? "running" : "stopped", labels: labels) + let mock = MockProcessRunner() + mock.handlers = [{ args in + if args.first == "list" { return .init(exitCode: 0, stdout: try! JSONSerialization.data(withJSONObject: [entry]), stderr: Data()) } + if args.first == "inspect" { return .init(exitCode: 0, stdout: try! JSONSerialization.data(withJSONObject: entry), stderr: Data()) } + return nil + }] + var warnings: [String] = [] + StatusPrinter.onWarning = { warnings.append($0) } + let runtime = AppleContainerRuntime(executablePath: "/synthetic/container", runner: mock) + let env = agent ? ["SSH_AUTH_SOCK": socket.path] : [:] + if start { try StartCommand.run(options: .init(name: resolved.containerName), runtime: runtime, localEnv: env) } + else { + var stdout = "" + fflush(nil) + try withCapturedStdout({ + _ = try UpCommand.run(options: .init(workspacePath: ws.path, jsonOutput: true, skipPull: true), runtime: runtime, localEnv: env, isTTY: false) + }, capture: &stdout) + let result = try JSONSerialization.jsonObject(with: Data(stdout.utf8)) as? [String: Any] + try MiniTest.expectEqual(result?["outcome"] as? String, "success") + try MiniTest.expect(!stdout.contains("rebuild") && !stdout.contains(socket.path)) + } + try MiniTest.expect(!mock.calls.contains { ["create", "delete"].contains($0.arguments.first ?? "") }) + try MiniTest.expect(!mock.calls.contains { $0.arguments.contains("--ssh") }) + let hints = warnings.filter { $0.contains("SSH forwarding is fixed") } + try MiniTest.expectEqual(hints.count, marker != "true" && agent && hasSSH ? 1 : 0) + if let hint = hints.first { + try MiniTest.expect(hint.contains("container dev rebuild --name \(resolved.containerName)") && hint.contains("if unavailable")) + try MiniTest.expect(!hint.contains(socket.path)) + } + } + } + } + } + }), + ("ssh.runtime.foundationPayloadAndRedactedFailure", { + let socket = try SSHSocketFixture() + let ws = try TestRepo.makeTempWorkspace(configJSON: #"{"image":"alpine:3.20","remoteUser":"root"}"#) + defer { try? FileManager.default.removeItem(at: ws) } + let resolved = try ConfigResolver.resolve(workspacePath: ws.path, localEnv: [:]) + let request = CreateRequest.from(resolved: resolved.config, identityName: "synthetic", labels: resolved.labels, configHash: resolved.configHash, workspacePath: ws.path, enableSSHForward: true) + let script = socket.directory.appendingPathComponent("container-fake") + let capture = socket.directory.appendingPathComponent("capture") + try Data("#!/bin/sh\nprintf '%s' \"$SSH_AUTH_SOCK\" > \"$CAPTURE\"\nif [ \"$FAIL\" = 1 ]; then printf '%s' \"$SSH_AUTH_SOCK\" >&2; exit 1; fi\nprintf 'synthetic\\n'\n".utf8).write(to: script) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) + for runner: any ProcessRunning in [FoundationProcessRunner(), SSHPlainFoundationRunner()] { + let runtime = AppleContainerRuntime(executablePath: script.path, runner: runner) + let env = ["SSH_AUTH_SOCK": socket.path, "CAPTURE": capture.path] + try MiniTest.expectEqual(try runtime.create(request: request, ensureVolumes: false, hostEnvironment: env), "synthetic") + try MiniTest.expectEqual(try String(contentsOf: capture, encoding: .utf8), socket.path) + _ = try runtime.invoke(["create"], streamStderr: true, environment: env) + try MiniTest.expectEqual(try String(contentsOf: capture, encoding: .utf8), socket.path) + try MiniTest.expectThrows({ + _ = try runtime.create(request: request, ensureVolumes: false, hostEnvironment: env.merging(["FAIL": "1"]) { _, new in new }) + }, validate: { error in + try MiniTest.expect(!((error as? CLIError)?.message.contains(socket.path) ?? false)) + }) + } + }) +] diff --git a/Tests/adevcontainerTests/CloneInVolumeTests.swift b/Tests/adevcontainerTests/CloneInVolumeTests.swift index 425d12c..20b133f 100644 --- a/Tests/adevcontainerTests/CloneInVolumeTests.swift +++ b/Tests/adevcontainerTests/CloneInVolumeTests.swift @@ -1631,6 +1631,25 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ $0.arguments.first == "exec" && $0.arguments.contains("-c") }) }), + ("cloneForwardingPolicyRemainsVolumeSpecific", { + GitCredentialLifecycle.setTestConnection(AutomaticCredentialCommandPort()) + defer { GitCredentialLifecycle.setTestConnection(nil) } + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + for url in ["git@example.invalid:group/repo.git", "https://example.invalid/repo.git"] { + for explicit in [false, true] { + let git = MockGitClient() + git.configJSONToWrite = "{\"image\":\"alpine:3.20\",\"runArgs\":\(explicit ? "[\"--ssh\"]" : "[]")}" + let mock = MockProcessRunner() + mock.handlers = CloneRuntimeMock.handlers() + _ = try CloneCommand.run(options: .init(gitURL: url, skipPull: true), runtime: .init(executablePath: "/synthetic/container", runner: mock), git: git, credentials: MockGitCredential(), localEnv: ["SSH_AUTH_SOCK": "/synthetic/nonexistent-agent"]) + let call = mock.calls.first { $0.arguments.first == "create" }! + try MiniTest.expectEqual(call.arguments.filter { $0 == "--ssh" }.count, 1) + try MiniTest.expect(call.environment == nil) + try MiniTest.expect(!call.arguments.contains { $0.hasPrefix("devcontainer.ssh_forwarded=") }) + } + } + }), ("cloneHTTPSPublicWorksWithoutHostCredentials", { let connection = AutomaticCredentialCommandPort(); GitCredentialLifecycle.setTestConnection(connection) defer { GitCredentialLifecycle.setTestConnection(nil) } @@ -3350,7 +3369,7 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ runtime: runtime ) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) - try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect(host.calls.allSatisfy { $0.arguments.suffix(2) == ["remote", "-v"] }, "already-running start permits only read-only SSH discovery, not initialize") try MiniTest.expect( !execBodies.contains("echo postStart-already-running"), "already-running start must not run postStart" diff --git a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift index feb3e93..b2b1476 100644 --- a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift +++ b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift @@ -2407,6 +2407,9 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ } let createWith = withAgent.mock.calls.first { $0.arguments.first == "create" }!.arguments try MiniTest.expect(createWith.contains("--ssh"), "--ssh forwarded with SSH_AUTH_SOCK") + try MiniTest.expectEqual(createWith.filter { $0 == "--ssh" }.count, 1) + try MiniTest.expect(!createWith.contains { $0.hasPrefix("devcontainer.ssh_forwarded=") }, "bind marker never changes volume policy") + try MiniTest.expect(withAgent.mock.calls.first { $0.arguments.first == "create" }!.environment == nil) let noAgent = RebuildScenario() let info2 = RebuildScenario.container(id: "vol-old", labels: noAgent.volumeLabels()) @@ -3723,7 +3726,7 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ try MiniTest.expect(events.firstIndex(of: "ownership")! < events.firstIndex(of: "setup")!) try MiniTest.expect(events.firstIndex(of: "setup")! < events.firstIndex(of: "hook")!) try MiniTest.expect(creds.fillCalls.isEmpty, "rebuild must not pre-acquire or persist a token") - try MiniTest.expect(hostRunner.calls.isEmpty, "bind discovery belongs to the injected connection") + try MiniTest.expectEqual(hostRunner.calls.map(\.arguments), [["-C", ws.path, "remote", "-v"]], "only read-only bind SSH discovery is added; credential acquisition stays in the connection") try MiniTest.expect(!s.mock.calls.contains { call in let joined = call.arguments.joined(separator: " ") return joined.contains("credential.helper") || joined.contains("git-credential-adev") || joined.contains("credential approve") || joined.contains("ghp_secret") diff --git a/Tests/adevcontainerTests/SSHForwardingPolicyTests.swift b/Tests/adevcontainerTests/SSHForwardingPolicyTests.swift new file mode 100644 index 0000000..51d41cc --- /dev/null +++ b/Tests/adevcontainerTests/SSHForwardingPolicyTests.swift @@ -0,0 +1,132 @@ +import Darwin +import Foundation +@testable import ADevContainerLib + +final class SSHDiscoveryRunner: ProcessRunning, @unchecked Sendable { + var output = "origin\tgit@example.invalid:group/repo.git (fetch)\n" + var exitCode: Int32 = 0 + var fails = false + var environments: [[String: String]?] = [] + var arguments: [[String]] = [] + func run(executable: String, arguments: [String], environment: [String: String]?, currentDirectory: String?, stdinData: Data?) throws -> ProcessResult { + environments.append(environment); self.arguments.append(arguments) + if fails { throw GitCredentialFailure.deadline } + return .init(exitCode: exitCode, stdout: Data(output.utf8), stderr: Data("secret diagnostic".utf8)) + } +} + +final class SSHSocketFixture { + let directory: URL + let path: String + private let fd: Int32 + init() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent("s-" + UUID().uuidString.prefix(6)) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + path = directory.appendingPathComponent("s").path + fd = socket(AF_UNIX, SOCK_STREAM, 0) + var address = try GitCredentialControlSocket.address(path) + guard fd >= 0, GitCredentialControlSocket.bindPrivate(fd, address: &address) == 0 else { + if fd >= 0 { close(fd) } + try? FileManager.default.removeItem(at: directory) + throw MiniTest.Failure(message: "synthetic SSH socket bind failed") + } + } + deinit { close(fd); try? FileManager.default.removeItem(at: directory) } +} + +@MainActor let sshForwardingPolicyTests: [(String, () throws -> Void)] = [ + ("ssh.policy.S01S04S05.remoteFormsAndPush", { + let socket = try SSHSocketFixture() + let runner = SSHDiscoveryRunner() + for remote in ["git@example.invalid:group/repo.git", "example.invalid:group/repo.git", "ssh://git@example.invalid/group/repo.git", "[::1]:group/repo.git", "git@[::1]:group/repo.git", "[2001:db8::1]:group/repo.git", "example.invalid:group::repo.git"] { + runner.output = "origin\thttps://user:secret@example.invalid/repo (fetch)\nother\t\(remote) (push)\nother\t\(remote) (fetch)\n" + let env = ["SSH_AUTH_SOCK": socket.path, "SYNTHETIC": "injected"] + let decision = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: env, explicitSSH: false, runner: runner, gitPath: "/fake/git") + try MiniTest.expect(decision.automatic) + try MiniTest.expect(decision.warning == nil) + try MiniTest.expectEqual(runner.environments.last!, env.merging(["GIT_TERMINAL_PROMPT": "0"]) { _, new in new }) + try MiniTest.expectEqual(runner.arguments.last!, ["-C", "/synthetic", "remote", "-v"]) + } + }), + ("ssh.policy.S02.socketMetadataNotNonempty", { + let socket = try SSHSocketFixture() + let file = socket.directory.appendingPathComponent("file") + try Data().write(to: file) + let link = socket.directory.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: socket.path) + for value in [nil, "", " ", file.path, socket.directory.path, socket.path + "-missing", socket.path, link.path] as [String?] { + let env = value.map { ["SSH_AUTH_SOCK": $0] } ?? [:] + let decision = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: env, explicitSSH: false, runner: SSHDiscoveryRunner(), gitPath: "/fake/git") + let expected = value == socket.path || value == link.path + try MiniTest.expectEqual(decision.automatic, expected) + try MiniTest.expectEqual(decision.warning != nil, !expected) + if let warning = decision.warning { + try MiniTest.expect(warning.contains("SSH_AUTH_SOCK") && warning.contains("rebuild")) + try MiniTest.expect(!warning.contains(socket.directory.path)) + } + } + }), + ("ssh.policy.S03S06.safeFallbackAndExplicit", { + let socket = try SSHSocketFixture() + let runner = SSHDiscoveryRunner() + for remote in ["https://user:secret@example.invalid/repo", "http://example.invalid/repo", "file:///local/repo", "/local/user@host:repo", "folder/user@host:repo", "./host:repo", "../host:repo", "other://host/repo", ""] { + runner.output = "origin\t\(remote) (fetch)\n" + let decision = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: ["SSH_AUTH_SOCK": socket.path], explicitSSH: false, runner: runner, gitPath: "/fake/git") + try MiniTest.expect(!decision.automatic && decision.warning == nil, remote) + } + runner.output = "origin\tgit@example.invalid:repo (fetch)\n" + for explicit in [false, true] { + runner.fails = true + let decision = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: [:], explicitSSH: explicit, runner: runner, gitPath: "/fake/git") + try MiniTest.expect(!decision.automatic && decision.warning == nil) + } + runner.fails = false; runner.exitCode = 128 + let failed = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: [:], explicitSSH: false, runner: runner, gitPath: "/fake/git") + try MiniTest.expect(!failed.automatic && failed.warning == nil) + let missing = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: ["PATH": "/nonexistent"], explicitSSH: false, runner: runner) + try MiniTest.expect(!missing.automatic && missing.warning == nil) + let explicit = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: [:], explicitSSH: true, runner: runner, gitPath: "/fake/git") + try MiniTest.expect(!explicit.automatic && explicit.warning == nil) + }), + ("ssh.policy.realLocalGitAndBoundedDiscovery", { + let socket = try SSHSocketFixture() + let env = ["HOME": socket.directory.path, "PATH": "/usr/bin:/bin", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "SSH_AUTH_SOCK": socket.path] + let runner = FoundationProcessRunner() + for args in [["init", socket.directory.path], ["-C", socket.directory.path, "config", "remote.origin.url", "https://user:secret@example.invalid/repo"], ["-C", socket.directory.path, "config", "remote.origin.pushurl", "git@example.invalid:group/repo.git"]] { + let result = try runner.run(executable: "/usr/bin/git", arguments: args, environment: env, currentDirectory: nil) + try MiniTest.expect(result.succeeded) + } + let decision = SSHForwardingPolicy.evaluate(workspace: socket.directory.path, environment: env, explicitSSH: false, runner: runner) + try MiniTest.expect(decision.automatic && decision.warning == nil) + let executable = socket.directory.appendingPathComponent("slow-git") + try Data("#!/bin/sh\n/bin/sleep 10\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + let start = ProcessInfo.processInfo.systemUptime + let timeout = SSHForwardingPolicy.evaluate(workspace: socket.directory.path, environment: env, explicitSSH: false, runner: CredentialProviderProcessRunner(timeout: 0.1), gitPath: executable.path) + try MiniTest.expect(!timeout.automatic && timeout.warning == nil) + try MiniTest.expect(ProcessInfo.processInfo.systemUptime - start < 3) + }), + ("ssh.policy.remoteHelpersNeverAutomaticallyForward", { + let socket = try SSHSocketFixture() + let runner = SSHDiscoveryRunner() + for remote in ["hg::/synthetic/repository", "https::https://example.invalid/repo", "ext::ssh-example", "hg::git@example.invalid:group/repo", "ssh::example.invalid/repo"] { + runner.output = "origin\t\(remote) (fetch)\norigin\t\(remote) (push)\n" + for agent in [false, true] { + for explicit in [false, true] { + let decision = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: agent ? ["SSH_AUTH_SOCK": socket.path] : [:], explicitSSH: explicit, runner: runner, gitPath: "/fake/git") + try MiniTest.expect(!decision.automatic && decision.warning == nil, remote) + let config = ResolvedDevContainerConfig(image: "alpine:3.20", workspaceFolder: "/workspaces/synthetic", runArgs: explicit ? [.ssh] : []) + let request = CreateRequest.from(resolved: config, identityName: "synthetic", labels: [:], configHash: "synthetic", workspacePath: "/synthetic", enableSSHForward: decision.automatic) + try MiniTest.expectEqual(request.createArguments().filter { $0 == "--ssh" }.count, explicit ? 1 : 0) + } + } + } + }), + ("ssh.policy.disablesTraceFilesAndCredentialEnvironment", { + let runner = SSHDiscoveryRunner() + _ = SSHForwardingPolicy.evaluate(workspace: "/synthetic", environment: ["GIT_TRACE": "/synthetic/secret-trace", "GIT_TRACE2_EVENT": "/synthetic/secret-trace", "ADEVCONTAINER_GIT_TOKEN": "secret", "PATH": "/bin"], explicitSSH: false, runner: runner, gitPath: "/fake/git") + let env = runner.environments.last!! + try MiniTest.expect(env["GIT_TRACE"] == nil && env["GIT_TRACE2_EVENT"] == nil && env["ADEVCONTAINER_GIT_TOKEN"] == nil) + try MiniTest.expectEqual(env["GIT_TERMINAL_PROMPT"], "0") + }) +] diff --git a/Tests/adevcontainerTests/VSCodeOpenTests.swift b/Tests/adevcontainerTests/VSCodeOpenTests.swift index 2522b9b..1cc47bf 100644 --- a/Tests/adevcontainerTests/VSCodeOpenTests.swift +++ b/Tests/adevcontainerTests/VSCodeOpenTests.swift @@ -946,7 +946,7 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = 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") }, "remote discovery belongs to the connection, not a seed probe") + try MiniTest.expect(host.calls.filter { $0.arguments.contains("remote") }.allSatisfy { $0.arguments == ["-C", ws.path, "remote", "-v"] }, "SSH eligibility discovery remains read-only") try MiniTest.expectEqual(connection.setups, [.init(id: "adev-app-aaaabbbbcccc", hostWorkspace: ws.path, gitURL: nil, refresh: false)]) try MiniTest.expect(connection.events.firstIndex(of: "runtime.start")! < connection.events.firstIndex(of: "setup")!) try MiniTest.expectEqual( @@ -997,7 +997,7 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = options: StartOptions(name: "adev-app-aaaabbbbcccc"), runtime: runtime ) - 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.expect(!mock.calls.contains { $0.arguments.first == "start" }) }), ("initializeCommandFailureLeavesStoppedContainerStopped", { @@ -1441,7 +1441,7 @@ nonisolated(unsafe) let vscodeOpenCommandTests: [(String, () throws -> Void)] = } let mono = TerminalStyle.stripANSI(stderr) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) - try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect(host.calls.allSatisfy { $0.arguments.suffix(2) == ["remote", "-v"] }, "already-running start permits only read-only SSH discovery, not initialize") try MiniTest.expect( !execBodies.contains("echo postStart-already-status"), "already-running start must not run postStart" @@ -2284,7 +2284,7 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] } try MiniTest.expectEqual(launcher.calls.count, 0) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) - try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect(host.calls.allSatisfy { $0.arguments.suffix(2) == ["remote", "-v"] }, "already-running start permits only read-only SSH discovery, not initialize") try MiniTest.expect(!bodies.contains(where: { $0.contains("exit 99") })) try MiniTest.expect(!bodies.contains("echo postStart-already")) try MiniTest.expect( @@ -2350,7 +2350,7 @@ nonisolated(unsafe) let vscodePostAttachGateTests: [(String, () throws -> Void)] ) try MiniTest.expectEqual(launcher.calls.count, 1) try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "start" }) - try MiniTest.expect(host.calls.isEmpty, "already-running start must not run initialize") + try MiniTest.expect(host.calls.allSatisfy { $0.arguments.suffix(2) == ["remote", "-v"] }, "already-running start permits only read-only SSH discovery, not initialize") try MiniTest.expect(bodies.contains("echo start-attach")) try MiniTest.expect(!bodies.contains("echo postStart-already")) }), diff --git a/Tests/adevcontainerTests/main.swift b/Tests/adevcontainerTests/main.swift index ff07fd1..af5b9c5 100644 --- a/Tests/adevcontainerTests/main.swift +++ b/Tests/adevcontainerTests/main.swift @@ -21,6 +21,8 @@ TerminalStyle.colorOverride = false // Force-link test translation units by referencing their register functions. let credentialTests = gitCredentialBrokerPolicyTests + gitCredentialProtocolTests + guestGitCredentialArtifactTests + credentialDuplexProcessTests + automaticHostGitCredentialTests + gitCredentialRuntimeTests + gitCredentialBrokerServiceTests + gitCredentialRelayChannelTests + gitCredentialBrokerEntryTests + gitCredentialConnectionTests + credentialProviderCrashTests + automaticGitCredentialsCommandTests + gitCredentialBrokerSmokeTests + gitCredentialBrokerDetachedTests let allTests: [(String, () throws -> Void)] = credentialTests + + sshForwardingPolicyTests + + automaticSSHForwardingCommandTests + terminalStyleTests + statusPrinterTests + processRunnerFramingTests @@ -104,7 +106,9 @@ let automaticCloneTests = cloneCommandTests.filter { ["cloneHTTPSNonAzureUsesAut let automaticRebuildTests = rebuildPhaseTests.filter { ["rebuildBindSeedsFromHostRemotesBeforeHooks", "rebuildVolumeSeedsFromStampedGitURLWithoutEnumeration", "rebuildVolumeMissingGitURLSkipsSilently", "rebuildSeedingFailureWarnsAndContinues", "rebuildNeverSeedsOldContainerBeforeDelete", "rebuildBindIdentityOrderAndConnectionUser", "rebuildVolumeIdentityReadsGuestBeforeDelete", "rebuildGlobalIdentityFailureWarnsAndContinues", "rebuildVolumeGlobalFailurePreservesWorkspaceAndHook"].contains($0.0) } let automaticLifecycleTests = upTests.filter { $0.0.hasPrefix("upFreshCreate") || $0.0.hasPrefix("upReuseRunning") || $0.0.hasPrefix("upStartStopped") } + credentialRefreshCommandTests + gitCredentialDiscoveryTests + automaticRebuildTests + vscodeOpenCommandTests.filter { $0.0 == "realBindStartRunsInitializeCommandFromStampedHostPath" } let selectedTests: [(String, () throws -> Void)] -if ProcessInfo.processInfo.environment["ADEVCONTAINER_CREDENTIAL_TESTS_ONLY"] == "1" { +if ProcessInfo.processInfo.environment["ADEVCONTAINER_SSH_TESTS_ONLY"] == "1" { + selectedTests = sshForwardingPolicyTests + automaticSSHForwardingCommandTests +} else if ProcessInfo.processInfo.environment["ADEVCONTAINER_CREDENTIAL_TESTS_ONLY"] == "1" { selectedTests = credentialTests + legacyCredentialTests + automaticCloneTests + automaticLifecycleTests } else if ProcessInfo.processInfo.environment["ADEVCONTAINER_TESTS_SYNTHETIC_ONLY"] == "1" { // AllIntegrationTests contains runtime mutations/network checks behind its diff --git a/specs/changes/archive/20261022-automatic-ssh-forwarding-up/proposal.md b/specs/changes/archive/20261022-automatic-ssh-forwarding-up/proposal.md new file mode 100644 index 0000000..cf88229 --- /dev/null +++ b/specs/changes/archive/20261022-automatic-ssh-forwarding-up/proposal.md @@ -0,0 +1,26 @@ +# Proposal: Automatic SSH forwarding for local workspaces + +## Intent +Local SSH Git remotes should work in a newly created bind-mode dev container without requiring users to add `runArgs: ["--ssh"]`. Forward the existing host agent through Apple's create flag, not keys or a new credential service. + +## Scope +Add a core-domain requirement for automatic SSH forwarding on fresh bind `up` and explicit bind `rebuild`, with safe discovery, explicit-runArgs compatibility, and honest reuse/start guidance. The existing [core lifecycle](../../../core.md), [runArgs allowlist](../../../runargs-host.md), and [clone contracts](../../../clone.md) remain in force. + +## Non-goals +No public flags, guest SSH-agent emulation, key copying, authentication/keychain/key queries, broker/service or Homebrew changes. No changes to clone/volume forwarding policy (including its current HTTPS behavior), HTTP(S) credential connections, workspace identity, config drift rules, or wiki. Preserve pending documentation edits; only a concise user-facing README note is in scope. No automatic recreation or reconfiguration of existing containers. + +## Approach +Read local Git fetch and push remote URLs before bind creation, classify SSH without network access, and add the typed SSH runArg once when a host socket is present. Share that decision between bind `up` and bind `rebuild`; keep automatic forwarding separate from resolved config/hash material. Existing containers retain their create-time settings. + +## Decision index +- **Eligibility:** Any SSH fetch or push remote enables consideration, including mixed HTTPS/SSH repositories, `user@host:path`, `host:path`, and `ssh://` forms. HTTPS-only, local-path, missing-remotes and failed discovery do not enable automatic forwarding. Extend classification only in the bind policy if necessary; do not broaden clone behavior. +- **Availability:** For this bounded change, an available endpoint means a nonblank `SSH_AUTH_SOCK` in the invocation's `localEnv` resolving to a Unix-domain socket on the host. Follow socket symlinks. This metadata check does not establish liveness, loaded keys, authorization, or successful authentication; do not connect to/query the agent. Missing/stale/non-socket endpoints disable automatic forwarding with a nonfatal actionable warning when SSH is discovered. +- **Environment:** Use the injected invocation environment for both the decision and the host `container create` subprocess when forwarding automatically; never substitute the ambient process socket or inject the host socket path as guest `containerEnv`. Preserve the legacy runtime behavior of explicit-only `--ssh` even without an agent. +- **Explicit intent:** Preserve admitted `runArgs --ssh` regardless of remotes/socket availability and emit only one effective flag. Do not add a new automatic-missing-agent warning or reject an explicit-only request. +- **Existing containers:** A nonsecret internal create-time forwarding marker records the bind request's effective flag. For eligible existing bind containers whose marker is absent/false, emit conditional rebuild guidance rather than claiming inspection proves SSH is disabled. No flag repair on `up` reuse/start-stopped or bare `start`; already-marked forwarded containers need no rebuild hint. Agent/remote changes alone do not change the config hash. +- **Runtime limit:** Read-only installed CLI help confirms `container create --ssh` and no SSH option on `container start`; synthetic tests must lock down the no-create/no-mutation start/reuse behavior. This is not real SSH authentication validation. +- **Artifact size:** Proposal decisions and concrete tasks suffice; no design overflow document is needed. + +## Clarifications +- **Q:** Which local workspaces automatically expose the agent? + **A:** The accepted policy is SSH remotes plus an available host endpoint only; missing endpoint warns without copying keys, HTTPS-only does not automatically forward, and explicit `runArgs --ssh` remains respected. diff --git a/specs/changes/archive/20261022-automatic-ssh-forwarding-up/spec.md b/specs/changes/archive/20261022-automatic-ssh-forwarding-up/spec.md new file mode 100644 index 0000000..aad8be6 --- /dev/null +++ b/specs/changes/archive/20261022-automatic-ssh-forwarding-up/spec.md @@ -0,0 +1,58 @@ +# Change Spec: automatic-ssh-forwarding-up + +Domain: `core` (additive to [core](../../../core.md); preserves [clone](../../../clone.md) and [runArgs](../../../runargs-host.md)). + +## ADDED Requirements + +### Requirement: Automatic SSH forwarding for bind workspaces +Fresh bind-mode `up` and explicit bind-mode `rebuild` MUST request exactly one effective Apple create `--ssh` when at least one local fetch or push remote uses SSH and the invocation's host `SSH_AUTH_SOCK` resolves to a Unix-domain socket. Both paths MUST use the same policy and the same invocation environment for endpoint selection and automatic-forwarding create execution. Socket presence MUST NOT be presented as proof of agent liveness, loaded keys, or authentication success. Discovery MUST be host-local, noninteractive, best-effort and bounded, without guest execution, network operations, credential acquisition, or disclosure of remote URLs/credentials in diagnostics or newly persisted metadata. No repository, absent remotes, unavailable host Git, or discovery failure MUST leave automatic forwarding disabled without failing bring-up. + +An SSH remote with no such endpoint MUST continue without automatic forwarding and emit one actionable stderr warning explaining how to expose a host agent and use an explicit rebuild if the container has already been created. The CLI MUST NOT copy keys, query agent keys, install a guest SSH agent, or introduce new public flags or services. HTTPS-only and local-only remotes MUST NOT trigger automatic SSH forwarding or its missing-agent warning. Existing explicit `runArgs --ssh` MUST remain effective once regardless of socket/remotes, without a new product-side agent requirement or automatic-missing-agent warning; existing runtime failures remain possible. Clone/volume create and rebuild policy and HTTP(S) credential behavior MUST remain unchanged. + +#### Scenario: S01 - SSH remote and socket +- Given a local SSH remote and an invocation environment selecting a host Unix-domain socket +- When fresh bind `up` creates a container +- Then create includes one `--ssh` and uses that invocation environment, without asserting that SSH authentication succeeds. + +#### Scenario: S02 - SSH remote without usable socket +- Given an SSH remote and an unset, blank, nonexistent, or non-socket `SSH_AUTH_SOCK`, with no explicit `--ssh` +- When bind `up` or bind `rebuild` creates a container +- Then automatic `--ssh` is absent, one actionable stderr warning is emitted, and this omission alone does not fail the operation or copy keys. + +#### Scenario: S03 - HTTPS-only and local-only workspaces +- Given only HTTPS or local-path remotes, or no discoverable remotes, with or without a socket and no explicit `--ssh` +- When bind creation runs +- Then no automatic `--ssh` or missing-agent warning is added, and HTTPS credential behavior is unchanged. + +#### Scenario: S04 - Mixed transports and push-only SSH +- Given an HTTPS fetch URL and an SSH push URL, or separate HTTPS and SSH remotes, and a host socket +- When bind creation runs +- Then one automatic `--ssh` is included regardless of which remote is named `origin`. + +#### Scenario: S05 - SSH URL forms +- Given remotes using `git@example.invalid:group/repo.git`, `example.invalid:group/repo.git`, or `ssh://git@example.invalid/group/repo.git` +- When bind SSH eligibility is determined +- Then each is recognized as SSH, unlike absolute/relative local paths or non-SSH scheme URLs. + +#### Scenario: S06 - Explicit forwarding remains explicit +- Given admitted `runArgs` containing `--ssh`, for SSH or HTTPS-only remotes and with or without a host socket +- When bind creation runs +- Then create contains one `--ssh`, the explicit-only environment/runtime behavior is preserved, and automatic policy does not reject the request or emit its missing-agent warning. + +#### Scenario: S07 - Explicit bind rebuild refreshes forwarding +- Given a bind container and current local remotes/environment +- When the user explicitly invokes `rebuild` +- Then the replacement uses the same forwarding decision as fresh bind `up`, while normal preflight, container-only deletion, and volume preservation remain unchanged; clone-origin volume rebuild keeps its prior policy. + +### Requirement: Existing bind containers retain create-time SSH settings +`up` reuse, `up` start-stopped and bare `start` MUST NOT add/remove SSH forwarding, recreate a container, or emulate forwarding in the guest. Changes in host socket availability or local remotes alone MUST NOT change config hash or force a mismatch/rebuild. When SSH plus a socket would qualify for automatic forwarding and existing bind forwarding is not recorded as enabled, these paths MUST emit nonfatal conditional guidance naming `adevcontainer rebuild --name ` (respecting the invocation prefix) to apply create-time SSH settings if unavailable. Unknown legacy state MUST NOT be described as verified disabled. Existing config-hash mismatch handling remains authoritative. + +#### Scenario: S08 - Reuse and start cannot add a create flag +- Given a same-config bind container without recorded forwarding (including a legacy container), newly eligible local SSH remotes and a host socket +- When `up` reuses it or starts it, or bare `start` selects it +- Then the existing container is preserved, no create/reconfigure/guest-SSH action occurs, and conditional rebuild guidance is emitted on stderr without contaminating JSON stdout or claiming verified absence. + +#### Scenario: S09 - Environmental changes do not force rebuild +- Given a same-config bind container with forwarding recorded enabled, whose remotes or host socket changed +- When `up` or `start` runs +- Then create-time settings and identity remain unchanged, no automatic replacement or new config-hash mismatch occurs, and no missing-forwarding rebuild hint is emitted. diff --git a/specs/changes/archive/20261022-automatic-ssh-forwarding-up/tasks.md b/specs/changes/archive/20261022-automatic-ssh-forwarding-up/tasks.md new file mode 100644 index 0000000..a7ac793 --- /dev/null +++ b/specs/changes/archive/20261022-automatic-ssh-forwarding-up/tasks.md @@ -0,0 +1,127 @@ +# Tasks: automatic-ssh-forwarding-up + +Spec ref: `specs/changes/archive/20261022-automatic-ssh-forwarding-up/` ([delta](spec.md), [proposal](proposal.md), [realized core](../../../core.md)). Implementation dispatched after analyze PASS. Each test phase must demonstrate a relevant failure before implementation, then pass. Run tests with `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1` (and optionally `ADEVCONTAINER_SSH_TESTS_ONLY=1` for focused coverage). Use synthetic remotes, injected host runners/environment and temporary unconnected Unix sockets only; no actual containers, network Git, agent/keychain/key queries, or authentication claims. + +## 1. Policy tests first +- [x] 1.1 Add policy tests for S01–S06: fetch/push remote parsing, deduplication, mixed and push-only SSH, all SSH URL forms, local/other URL exclusions, missing Git/nonrepo/nonzero/timeout, and credential-bearing remote output never appearing in warnings/metadata. Cover absent/blank/nonexistent/file/directory/socket/symlink endpoints and ambient-vs-injected environment disagreement. (path: Tests/adevcontainerTests/SSHForwardingPolicyTests.swift) +- [x] 1.2 Register the new test collection and record expected red failures before implementation. (path: Tests/adevcontainerTests/main.swift) + +## Checkpoint +- [x] S01–S06 have deterministic tests; the new policy is not inferred from nonempty environment strings or real agent access. + +## 2. Implement bounded bind policy after red +- [x] 2.1 Add the shared bind-only decision/discovery helper. Invoke host Git argv `-C remote -v` through an injected bounded process runner using `localEnv`, parse fetch and push entries without logging raw output/errors, and classify with `GitURLClassifier` plus narrowly scoped userless SCP handling. Missing Git/discovery failure returns no automatic request. Resolve socket metadata without connecting; return automatic decision, warning, and nonsecret forwarding-marker/hint helpers. Do not change HTTPS fetch-only discovery or global clone classification. (path: Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift) + +## Checkpoint +- [x] S01–S06 policy tests pass; discovery invokes no guest exec, SSH, credential provider, shell, or network operation. + +## 3. Integration tests before runtime/lifecycle edits +- [x] 3.1 Add synthetic command/runtime tests for S01–S09: final argv count, effective-marker true/false, host create environment matching injected endpoint and not guest env, explicit-only legacy behavior, bind rebuild parity, warnings before hooks, running/stopped/legacy reuse and bare start with no create/delete/reconfigure, config-hash stability, forwarded-container hint suppression, prefix-aware hints and clean JSON stdout. Foundation fake-executable coverage also checks host environment and redacted failures. (path: Tests/adevcontainerTests/AutomaticSSHForwardingCommandTests.swift) +- [x] 3.2 Register the integration collection and record expected red failures before integration edits; add a focused synthetic SSH selector. (path: Tests/adevcontainerTests/main.swift) +- [x] 3.3 Add regression assertions for existing clone SSH missing/present-agent handling, explicit deduplication and current HTTPS-with-socket behavior; no stricter socket metadata requirement may leak into clone. Allow only read-only discovery in existing bind-start assertions. (path: Tests/adevcontainerTests/CloneInVolumeTests.swift) +- [x] 3.4 Extend volume rebuild socket/no-socket coverage and adjust bind host-discovery assertions without changing credential acquisition expectations. (path: Tests/adevcontainerTests/RebuildCommandPhaseTests.swift) +- [x] 3.5 Assert the image-copy-up helper does not inherit SSH exposure and allow read-only discovery, not initialization/acquisition, on existing bind containers. (path: Tests/adevcontainerTests/AllCommandTests.swift) +- [x] 3.6 Keep start/open/hook assertions while admitting only the new read-only SSH eligibility discovery. (path: Tests/adevcontainerTests/VSCodeOpenTests.swift) + +## Checkpoint +- [x] Command tests initially fail on missing bind behavior, not external dependencies. Existing clone/volume regressions passed in the policy baseline; expanded regressions pass in the final suite. + +## 4. Integrate after red +- [x] 4.1 Add optional bind forwarding input to `CreateRequest.from`, retaining explicit runArgs and deduplicating `.ssh`; stamp a boolean-only internal `devcontainer.ssh_forwarded` marker for bind creates from the effective flag. Do not mutate resolved config/hash or volume policy. (path: Sources/ADevContainerLib/Runtime/CreateRequest.swift) +- [x] 4.2 Add narrow optional host-environment plumbing for the main create subprocess in streaming/nonstreaming paths, leaving defaults and helper requests unchanged. The automatic bind caller supplies `localEnv`; explicit-only calls preserve inherited behavior. Never translate host socket selection into guest `-e` flags. Capture automatic-create diagnostics and redact the selected endpoint before reporting a failure. (path: Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift) +- [x] 4.3 Evaluate shared policy on fresh bind create and pass its result/environment to the runtime; warn once before create-path hooks. On matching-hash running/stopped reuse, issue conditional guidance only for eligible unmarked/false-marker containers; do not run repair, affect hashes, or bypass existing drift errors. (path: Sources/ADevContainerLib/Commands/UpCommand.swift) +- [x] 4.4 Resolve bind policy from stamped host workspace and current `localEnv` before the delete boundary; apply it to replacement bind creation with the same environment and warning behavior. Keep volume branch, preflight/delete sequencing, recovery and volumes unchanged. (path: Sources/ADevContainerLib/Commands/RebuildCommand.swift) +- [x] 4.5 Add host-only conditional guidance for selected bind containers, both stopped and already running, using shared eligibility/marker logic. Start still has no SSH flag and never repairs forwarding or runs guest SSH probes. (path: Sources/ADevContainerLib/Commands/StartCommand.swift) + +## Checkpoint +- [x] S01–S09 command tests pass; ambient/injected environment disagreement is covered at the recorded runtime process boundary and a Foundation-launched fake executable, not only the decision helper. + +## 5. Verify and hand off +- [x] 5.1 Run debug/release-product builds and the full synthetic suite of record; map S01–S09 to passing tests and inspect the diff. See evidence and safety incident below; no real forwarding/authentication validation is claimed. (path: specs/changes/archive/20261022-automatic-ssh-forwarding-up/tasks.md) +- [x] 5.3 Add only a short user-facing automatic SSH/rebuild note while preserving pending README edits; detailed wiki ingestion belongs to the coordinator after review. (path: README.md) +- [x] 5.2 Converge implementation against persisted proposal/spec/tasks, obtain independent conformance review, and return to the coordinator for the authorized archive step only after verification. Independent re-review APPROVE was supplied in the archive handoff. (path: specs/changes/archive/20261022-automatic-ssh-forwarding-up/spec.md) + +## Analyze +PASS (planning readiness): source core/clone/runArgs contracts consulted; no active delta folders existed at planning time; no legacy nested source specs found. Additive core requirements preserve drift/reuse semantics, explicit runArgs, clone/volume policy and HTTP(S) credentials. S01–S06 map to phases 1–4; S07 maps to 3.1/3.4/4.4; S08–S09 map to 3.1/4.3/4.5; security/environment constraints map to 1.1/2.1/3.1/4.2. No unresolved clarification markers or design overflow. This PASS is artifact readiness, not implementation/test verification. + +Analyze rerun: PASS before the documentation addition; the subsequent handoff authorizes only a concise README note. It changes no forwarding outcome. The explicit synthetic-only test command corrects execution safety, not product scope. Automatic create diagnostics are captured/redacted to avoid leaking the selected host socket endpoint; explicit-only/volume runtime behavior stays unchanged. + +Review-correction analyze: PASS before the helper-classification fix. Git `::
` is a remote-helper transport, not an SSH remote merely because it contains a colon. Excluding it refines existing SSH-only S03/S05 coverage, while S06 preserves explicit `--ssh`; it adds no normative outcome and does not change the clone classifier. New synthetic RED coverage fails for `hg::/synthetic/repository` and also checks helper addresses containing user/host syntax, missing/present sockets, explicit requests, bracketed IPv6 and colons inside valid SCP paths. Selectors are asserted before launching every Swift test. + +## Review correction: remote-helper exclusion +- [x] RC1 Add helper-negative, explicit-request and valid IPv6/SCP regression cases; reproduce the reviewer failure before changing product code. (path: Tests/adevcontainerTests/SSHForwardingPolicyTests.swift) +- [x] RC2 Exclude the leading Git helper `::` separator before SSH/SCP classification without rejecting IPv6 literals or valid SCP path contents. (path: Sources/ADevContainerLib/Git/SSHForwardingPolicy.swift) +- [x] RC3 Verify focused/full synthetic suites, debug/release-product builds and whitespace; record evidence without runtime actions. (path: specs/changes/archive/20261022-automatic-ssh-forwarding-up/tasks.md) + +Review-correction evidence (supersedes the earlier nine-group/1212-test totals): `ssh-helper-red.log` reproduces the blocker before the fix (nine groups pass; `ssh.policy.remoteHelpersNeverAutomaticallyForward` fails on `hg::/synthetic/repository`). After the four-line bind-policy guard, `ssh-helper-focused-green.log` records **10 groups passed**, and `ssh-helper-synthetic-green.log` records **1213 passed, zero failures/skips, 18 live integration cases explicitly excluded**. The new table checks five helper forms, both fetch/push entries, present/absent synthetic sockets and explicit `--ssh`, including final create-argv counts; positive coverage preserves SSH URLs, SCP, bracketed IPv6 and `::` inside SCP paths. `ssh-helper-debug-build.log` and `ssh-helper-release-build.log` record successful `swift build --product adevcontainer` and `swift build -c release --product adevcontainer`. All logs are under the same approved external `opencode/` directory. The launcher asserts `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1` before every Swift invocation, verifies the SSH selector for the focused suite, and unsets only the SSH selector for the full synthetic suite. Existing CLT linker-path warnings remain; no new SSH-code warning was reported. No runtime command, real container/image pull, authentication, host-key/config access, incident-cache action, README/wiki change, or outcome-spec edit occurred during this correction. At that stage, independent security re-review and task 5.2 were pending and no archive had been performed; see the final archive record below. + +| Scenario | Test tasks | Implementation tasks | +|---|---|---| +| S01 | 1.1, 3.1 | 2.1, 4.1–4.3 | +| S02 | 1.1, 3.1 | 2.1, 4.3–4.4 | +| S03 | 1.1, 3.1 | 2.1, 4.3–4.4 | +| S04 | 1.1, 3.1 | 2.1, 4.3–4.4 | +| S05 | 1.1, 3.1 | 2.1 | +| S06 | 1.1, 3.1, 3.3 | 2.1, 4.1–4.4 | +| S07 | 3.1, 3.4 | 4.4 | +| S08 | 3.1 | 4.3, 4.5 | +| S09 | 3.1 | 4.1, 4.3, 4.5 | + +## Execution evidence and limitations +- Policy RED: `ssh-policy-red.log` records the missing `SSHForwardingPolicy` compile failure before implementation. Synthetic baseline after policy implementation: 1205 passed, zero failures. +- Command RED: `ssh-commands-red.log` records three behavioral failures (missing bind marker/forwarding, rebuild forwarding, existing-container hint), with the three original policy cases passing. Later safety RED tests caught relative local-path misclassification, inherited Git tracing/token environment, and unredacted create diagnostics; these were corrected before GREEN. +- Final focused command: `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 ADEVCONTAINER_SSH_TESTS_ONLY=1 swift run adevcontainerTests` — nine test groups passing, exercising S01–S09 through table-driven cases plus real local Git config, bounded synthetic executable timeout, unconnected Unix sockets and Foundation-launched fake runtime payloads. +- Full synthetic command: `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1 swift run adevcontainerTests` — 1212 passed, zero failures/skips; 18 live integration tests explicitly excluded. Existing lifecycle tests were adjusted only to admit read-only `git -C ... remote -v` discovery, retaining no-initialize/no-acquisition/no-create assertions. +- Build: `swift build` passed. `swift build -c release --product adevcontainer` passed. Bare `swift build -c release` also attempts the executable test product and failed to resolve its `@testable import ADevContainerLib` against the non-testing release library; no package/testability changes were made outside scope. +- Warnings: existing CLT linker search-path warnings and existing captured-variable warnings at `CredentialDuplexProcessTests.swift:67–68` remain; no warning points to changed/new SSH code. `git diff --check` passed. +- Logs reside in the approved external temporary directory under `opencode/`: `ssh-policy-red.log`, `ssh-policy-synthetic.log`, `ssh-commands-red.log`, `ssh-safety-red.log`, `ssh-boundary-red.log`, `ssh-focused-final.log`, `ssh-synthetic-final.log`, `ssh-debug-build.log`, `ssh-release-build.log`, `ssh-release-product.log`. +- **Safety incident:** An early invocation mistakenly omitted `ADEVCONTAINER_TESTS_SYNTHETIC_ONLY=1`. Its log (`ssh-policy-green.log`) shows live integration fixtures and real image pulls. The initial report incorrectly treated SIGTERM attempts as proof that execution stopped: later forensic reading shows that this run reached its final summary. Subsequent test executions explicitly selected synthetic tests. No real SSH authentication was validated. The restricted cleanup follow-up below records actual resources and remaining uncertainty; this execution does not claim the original no-live-runtime constraint was met. +- New synthetic fixture directories, local Git configuration, unconnected sockets and fake executables are confined to their temporary roots with deferred cleanup. No agent keys, external SSH or production broker state are used by the new test groups. No runtime cleanup or fixture deletion outside those owned roots was added. +- At the initial implementation handoff, independent review was pending and task 5.2 was intentionally unchecked. No archive, commit, branch operation, realized/archived spec edit, or wiki edit had occurred. Pending README/wiki content was preserved; README gained only the short local-SSH note. + +## Incident inventory and restricted cleanup — 2026-10-22 + +### Execution established from evidence +- Original command: `swift run adevcontainerTests > /opencode/ssh-policy-green.log 2>&1`, without the synthetic selector. Log birth/last-write bracket: **12:40:34–12:45:36 UTC** (09:40:34–09:45:36 UTC−03:00). These are file timestamps, not per-test start/end timestamps. SHA-256: `3fa7e84453c8bb53f0e29c0e9b9df2103247f6a905f8222ae9563fee1c1ff80a`. +- Observed original processes were test runner PID `8338` and its image-pull child PID `8963`. SIGTERM was attempted, but successful termination at that time was not verified. Neither PID exists now, and the current process inventory contains no `adevcontainerTests` runner. No new tests or production workloads were launched during cleanup. +- Final original-run summary: **1217 passed, zero failed, six skipped**. The integration group contains five mock-only passing cases, seven real passing container fixtures, one image-pull skip, and five feature/recovery opt-in skips. The two feature opt-in cases perform public `ghcr.io/v2/` reachability probes before their skip gate; no opt-in feature/recovery container flow ran. + +| Real passing fixture | Exact container id | Logged temporary workspace basename | Disposition | +|---|---|---|---| +| smoke | `smoke` | `adev-int-9C17CA30-B4E4-447D-B50D-1A7BA4FE6DCA` | Log line 1546 confirms deletion | +| mounts/ports | `mounts-ports` | `adev-int-915F36F6-9483-40D1-9DB6-8F197ADE53D9` | Log line 1555 confirms deletion | +| lifecycle | `lifecycle` | `adev-int-772FA0C8-1850-4292-A122-5725E8558D4E` | Log line 1568 confirms deletion | +| lifecycle hooks | `lifecycle-hooks` | `adev-int-1BFA0370-4654-4550-B354-796D61F3DA7A` | Log line 1578 confirms deletion | +| runArgs/host requirements | `runargs-hostrequirements` | `adev-int-86F82C42-D3FB-40E7-BF18-6F8B143F6B60` | Log line 1601 confirms deletion | +| bare Debian | `debian` | `adev-int-BFDD01EA-B6FE-4756-BA74-BB68C4AC39B7` | Log line 1720 confirms deletion | +| local Features | `features-local-sample-a-sample-b` | `adev-int-42C91F27-6753-4285-A5B6-B17FC9930810` | Log line 1727 confirms deletion | + +- Current runtime inventory confirms none of these seven containers exists. All seven exact logged workspace paths are absent. The `envUser` fixture skipped after the dotnet image-pull failure; no successful container creation for it is logged. The pull output includes `unknown command 'images'`; its cause is not established from the log. +- Image-transfer output and fixture definitions establish real use of `mcr.microsoft.com/devcontainers/base:ubuntu`, `mcr.microsoft.com/devcontainers/dotnet:10.0` (initial failed attempt, later fixtures succeeded), and `ghcr.io/bare-devcontainer/debian:trixie`. Local Features performed a real image build, executed both sample installers and hooks, and exported manifest-list digest `sha256:4502a3697b82ab001f54ba7f492b387d7501661a6a9f4772cbff19131c41e598` (log line 1709). Registry image creation dates do not prove prior local-cache absence. +- **Preexisting host-directory exposure escalated:** `mounts-ports.json` binds host `~/.kube` read-only and the passing fixture checks its directory exists. Directory metadata confirms it predates this run (birth 2026-08-09; mtime 2026-10-21). Its contents were not inspected during this investigation. There is no log evidence of key reads or host writes, but read-only exposure did occur; no reconstruction/restoration was attempted. Fixture configuration also mounted three named volumes, whose runtime creation timestamps below establish that those particular volume resources were created during this run rather than before it. + +### Exact cleanup and provenance +Before every deletion batch, read-only machine metadata listed all three current containers, then individually inspected their mounts/image descriptors. All mount schemas were validated, and no container referenced any of the four target logical volume names or the exact derived image. No deletion was selected by generic prefix. Only these actions ran, each exiting zero: + +| Resource | Creation timestamp (UTC) / attribution | Exact action | +|---|---|---| +| `opencode-config` | 12:43:23; exact mounts/ports fixture volume | `container volume delete opencode-config` | +| `opencode-data` | 12:43:23; exact mounts/ports fixture volume | `container volume delete opencode-data` | +| `opencode-state` | 12:43:24; exact mounts/ports fixture volume | `container volume delete opencode-state` | +| `sample-b-data` | 12:45:33; exact local sample-b Feature volume | `container volume delete sample-b-data` | +| `docker.io/library/adev-adev-int-42c91f27-67:68b70fff4478` | 12:45:29; UUID-derived workspace tag plus exact exported digest above | `container image delete docker.io/library/adev-adev-int-42c91f27-67:68b70fff4478` | + +The four volume creation timestamps fall within the run window and match the exact successful fixture definitions. The generated image's tag embeds the logged random workspace identity and its current digest matches the build export. No resource was deleted merely because its name looked test-related. Image deletion reported **970.6 MB reclaimed**. No container stop/delete, host-directory deletion, generic image deletion, prune, authentication, key/credential query, or production-service mutation was performed during cleanup. + +### Preserved resources and verification boundary +- **No pre-incident runtime baseline exists.** Full restoration to pre-incident state is not asserted. A cleanup-local metadata baseline was taken before any removal, and compared after removal. +- All three current containers have identical identity, state, start time, creation time, label hash, mount hash and image hash before/after cleanup. This includes the two preexisting user containers (one running, one stopped), identified in sanitized evidence by id-hash prefixes `7cfae66cefc10690` and `e372377996ec7b1a`. Neither was targeted by a mutating command. +- A shared runtime-managed `buildkit` container was created at 12:45:17 during the Features build and is currently stopped. It is preserved: it is a shared Apple builder, not a UUID-named fixture, and no prior builder/config baseline exists to distinguish a new service from replacement/reconfiguration. Its shared host build-export directory/cache was not modified by cleanup. No fixture container or test-runner workload remains active. +- Six other volumes and five shared images remain, with inventory identities unchanged except for the exact removals above. Shared base/dotnet/Debian images, builder/vminit images, registry download/content caches and shared builder caches were preserved because exclusive provenance/prior absence cannot be proved. Their remaining disk footprint is not attributed solely to this run. No image cache pruning was attempted. +- Original log and before/after/action evidence are retained under `/opencode/`: `ssh-incident-before-cleanup-metadata.json`, `ssh-incident-inspect-before-cleanup.json`, `ssh-incident-cleanup-actions.json`, `ssh-incident-after-cleanup-metadata.json` (evidence JSON permissions `0600`). Only metadata, identity hashes and exact fixture attribution were recorded; no container environment, credentials or key contents were captured. This follow-up edits incident evidence only, not product/spec outcomes. + +## Final verification and archive — 2026-10-22 +- [x] Independent re-review APPROVE supplied by the coordinator from session `ses_e9c44f75dffeRSCSgsmlDPHHZ3`, after resolving the remote-helper blocker. Persisted S01–S09 remain mapped above to passing policy/command tests; final retained evidence shows 10 focused groups and 1213 synthetic tests passing, with debug/release-product builds passing. This archive step reads that evidence and does not rerun tests or access the runtime. Socket metadata proves neither agent liveness nor SSH authentication. +- [x] Merge exactly the two ADDED requirements into [realized core](../../../core.md), verified byte-for-byte against the prior source plus the persisted delta's normative block. No requirements are modified or removed. [Clone](../../../clone.md) and [runArgs](../../../runargs-host.md) source files remain unchanged. +- [x] Archive exactly `proposal.md`, `spec.md`, and `tasks.md` at `specs/changes/archive/20261022-automatic-ssh-forwarding-up/`; no design artifact existed. Remove the now-empty active directory, validate relative links, and verify hashes of all 326 files outside the allowed source/bundle paths against the archive-step baseline. No older archive, code, tests, README or wiki changes belong to this step. +- [x] Preserve the full incident inventory, read-only preexisting `~/.kube` exposure, restricted cleanup evidence, retained shared caches/builder and lack of a pre-incident runtime baseline. Approval of the forwarding implementation does not erase the incident or assert complete restoration. Archive authorization was explicitly supplied in the user's approved request and the coordinator's archive dispatch. diff --git a/specs/core.md b/specs/core.md index b4bf9e6..9f935f6 100644 --- a/specs/core.md +++ b/specs/core.md @@ -2104,3 +2104,58 @@ Verification of a development build MUST use a unique private broker context and - When isolated verification starts, replaces, crashes, retires, fails midway, and receives a handled termination signal - Then CLI-exit survival, single-owner concurrency, idle exit, identity replacement, and no-automatic-crash-restart are demonstrated without any launchctl command, plist, production state, or real credential acquisition - And production installation/state remains unchanged, cleanup removes only test-owned processes and paths, and abrupt termination has a documented targeted cleanup procedure + +--- + +### Requirement: Automatic SSH forwarding for bind workspaces +Fresh bind-mode `up` and explicit bind-mode `rebuild` MUST request exactly one effective Apple create `--ssh` when at least one local fetch or push remote uses SSH and the invocation's host `SSH_AUTH_SOCK` resolves to a Unix-domain socket. Both paths MUST use the same policy and the same invocation environment for endpoint selection and automatic-forwarding create execution. Socket presence MUST NOT be presented as proof of agent liveness, loaded keys, or authentication success. Discovery MUST be host-local, noninteractive, best-effort and bounded, without guest execution, network operations, credential acquisition, or disclosure of remote URLs/credentials in diagnostics or newly persisted metadata. No repository, absent remotes, unavailable host Git, or discovery failure MUST leave automatic forwarding disabled without failing bring-up. + +An SSH remote with no such endpoint MUST continue without automatic forwarding and emit one actionable stderr warning explaining how to expose a host agent and use an explicit rebuild if the container has already been created. The CLI MUST NOT copy keys, query agent keys, install a guest SSH agent, or introduce new public flags or services. HTTPS-only and local-only remotes MUST NOT trigger automatic SSH forwarding or its missing-agent warning. Existing explicit `runArgs --ssh` MUST remain effective once regardless of socket/remotes, without a new product-side agent requirement or automatic-missing-agent warning; existing runtime failures remain possible. Clone/volume create and rebuild policy and HTTP(S) credential behavior MUST remain unchanged. + +#### Scenario: S01 - SSH remote and socket +- Given a local SSH remote and an invocation environment selecting a host Unix-domain socket +- When fresh bind `up` creates a container +- Then create includes one `--ssh` and uses that invocation environment, without asserting that SSH authentication succeeds. + +#### Scenario: S02 - SSH remote without usable socket +- Given an SSH remote and an unset, blank, nonexistent, or non-socket `SSH_AUTH_SOCK`, with no explicit `--ssh` +- When bind `up` or bind `rebuild` creates a container +- Then automatic `--ssh` is absent, one actionable stderr warning is emitted, and this omission alone does not fail the operation or copy keys. + +#### Scenario: S03 - HTTPS-only and local-only workspaces +- Given only HTTPS or local-path remotes, or no discoverable remotes, with or without a socket and no explicit `--ssh` +- When bind creation runs +- Then no automatic `--ssh` or missing-agent warning is added, and HTTPS credential behavior is unchanged. + +#### Scenario: S04 - Mixed transports and push-only SSH +- Given an HTTPS fetch URL and an SSH push URL, or separate HTTPS and SSH remotes, and a host socket +- When bind creation runs +- Then one automatic `--ssh` is included regardless of which remote is named `origin`. + +#### Scenario: S05 - SSH URL forms +- Given remotes using `git@example.invalid:group/repo.git`, `example.invalid:group/repo.git`, or `ssh://git@example.invalid/group/repo.git` +- When bind SSH eligibility is determined +- Then each is recognized as SSH, unlike absolute/relative local paths or non-SSH scheme URLs. + +#### Scenario: S06 - Explicit forwarding remains explicit +- Given admitted `runArgs` containing `--ssh`, for SSH or HTTPS-only remotes and with or without a host socket +- When bind creation runs +- Then create contains one `--ssh`, the explicit-only environment/runtime behavior is preserved, and automatic policy does not reject the request or emit its missing-agent warning. + +#### Scenario: S07 - Explicit bind rebuild refreshes forwarding +- Given a bind container and current local remotes/environment +- When the user explicitly invokes `rebuild` +- Then the replacement uses the same forwarding decision as fresh bind `up`, while normal preflight, container-only deletion, and volume preservation remain unchanged; clone-origin volume rebuild keeps its prior policy. + +### Requirement: Existing bind containers retain create-time SSH settings +`up` reuse, `up` start-stopped and bare `start` MUST NOT add/remove SSH forwarding, recreate a container, or emulate forwarding in the guest. Changes in host socket availability or local remotes alone MUST NOT change config hash or force a mismatch/rebuild. When SSH plus a socket would qualify for automatic forwarding and existing bind forwarding is not recorded as enabled, these paths MUST emit nonfatal conditional guidance naming `adevcontainer rebuild --name ` (respecting the invocation prefix) to apply create-time SSH settings if unavailable. Unknown legacy state MUST NOT be described as verified disabled. Existing config-hash mismatch handling remains authoritative. + +#### Scenario: S08 - Reuse and start cannot add a create flag +- Given a same-config bind container without recorded forwarding (including a legacy container), newly eligible local SSH remotes and a host socket +- When `up` reuses it or starts it, or bare `start` selects it +- Then the existing container is preserved, no create/reconfigure/guest-SSH action occurs, and conditional rebuild guidance is emitted on stderr without contaminating JSON stdout or claiming verified absence. + +#### Scenario: S09 - Environmental changes do not force rebuild +- Given a same-config bind container with forwarding recorded enabled, whose remotes or host socket changed +- When `up` or `start` runs +- Then create-time settings and identity remain unchanged, no automatic replacement or new config-hash mismatch occurs, and no missing-forwarding rebuild hint is emitted. diff --git a/wiki/architecture.md b/wiki/architecture.md index 443201c..7f45a6d 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; 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) | +| `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` (**HTTP(S):** [automatic host credential connection](conventions/cli-runtime-boundary.md#automatic-git-credentials) before populate. 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 cfabd8d..f06c9c0 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -226,13 +226,33 @@ 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 +### Automatic Git credentials -Contract: [`specs/core.md`](../../specs/core.md) **Git credential store seeded on create paths**. Implementation symbol: `GuestGitCredentialSeed`. +Implementation entry points: `GitCredentialLifecycle` → `GitCredentialConnection` → `GitCredentialBrokerService`; the runtime supplies the relay transport through `AppleContainerRuntime.credentialBrokerPorts`. Setup connects the resolved connection user before the in-container clone or lifecycle hooks. The authoritative contracts remain in [`core.md` — credential helper](../../specs/core.md#requirement-git-credential-store-seeded-on-create-paths), [guest artifacts](../../specs/core.md#requirement-self-contained-guest-credential-artifacts), and [broker activation](../../specs/core.md#requirement-demand-started-detached-git-credential-broker); the historical “seeded” heading does not describe a current persistent token seed. -In plantsuite, the vscode user's `$HOME/.adevcontainer` stays `0755` because the VS Code marker uses `mkdir -p`. Credential prepare must not treat that mode as fatal — refusal blocks setup before any guest endpoint exists. Tighten to `0700` only for an own non-symlink directory; refuse symlinks and foreign-owned directories without `chmod`. Symbol: `GitCredentialConnection.privateDirectoryRepair`. +#### Host processes and lifetime -**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. +One per-user broker uses the same installed CLI binary, starts automatically on demand without sudo, and runs in a detached session that survives the invoking command. Activation and lifetime locks serialize ownership. It stays while registered containers or pending requests need it, then exits after five seconds without demand. `stop` unregisters the container and cancels its channel. A crash does not trigger an automatic restart: the next `up`, `start`, `clone`, or `rebuild` activates it again. There is no launchd job, LaunchAgent, login item, or brew service. Activation compares executable device/inode and build identity, reuses a matching broker, and gracefully retires an authenticated mismatched owner before starting a successor. + +Each active relay channel has a guardian, also the same CLI binary. The logical chain is: + +```text +adevcontainer (broker) → adevcontainer (guardian) → container exec → guest relay +``` + +The guest relay runs inside the container, not as a separate macOS process. Two same-named `adevcontainer` entries in Activity Monitor are therefore expected for one connected container: broker and guardian, not duplicate brokers. Multiple connected containers add guardians; short-lived host provider calls can add guardians too, so the count is not always exactly two. + +`CredentialDuplexProcess` starts the guardian as process-group leader. `CredentialProcessGuardianEntry` watches a parent-liveness pipe and child exit, killing its whole group on parent loss, child completion, or a provider deadline to avoid orphaned descendants. Persistent relay channels have no overall deadline; provider calls are bounded. Spawn resets inherited signal masks so host GCM does not hang on signals blocked by Dispatch workers. `GitCredentialRelayChannel` owns protocol reads and cancellation; this is separate from the interactive TTY process-group handling above. + +#### Providers, safety, and repair + +`HostGitCredential` uses the existing host Git helper chain, with optional `ADEVCONTAINER_GIT_TOKEN` override and GitHub CLI fallback. Acquisition disables prompts and credential tracing. The embedded guest relay needs no runtime installation of Python, socat, GCM, or a compiler. Private exec/socket channels carry credentials rather than guest token stores, argv, or product-created credential environment variables. Disk registrations hold container identity and destinations only; explicit token overrides stay in memory and need re-registration after broker exit. + +Discovery authorizes scheme/hostname/port while retaining sibling repository paths; an HTTPS registration does not authorize HTTP, and no unsafe-HTTP provider override is enabled. Azure's scheme-specific helper and `useHttpPath` configuration preserve the actual queried organization/repository path. Earlier user helpers keep their order and may answer before the product helper; those answers are outside its freshness guarantee. Migration removes only the identified old product-owned store, not unmarked legacy clone stores or user helpers. + +For repair, authenticate on the host and retry `up` or `start`; running reuse repairs the connection without fetch/pull/push or workspace writes. It does not clear rejection suppression: stop/start clears that finite block. Provider misses stay misses, and an unchanged token does not prove OAuth renewal. `GitCredentialConnection.privateDirectoryRepair` handles a connection-user-owned `$HOME/.adevcontainer` left at `0755` by VS Code marker creation by tightening it to `0700`; it refuses symlinks and foreign-owned directories without chmod. + +**Test seam:** lifecycle command tests injecting `RecordingHostProcessRunner` through `LifecycleRunner.hostProcessRunnerOverride` need to answer or tolerate the discovery probe (`git -C remote -v`). ## `clone` flow (volume-mode) @@ -248,7 +268,7 @@ In plantsuite, the vscode user's `$HOME/.adevcontainer` stays `0755` because the 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). 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. + - **HTTP(S) auth:** establish the [automatic Git credential connection](#automatic-git-credentials) before populate; the guest clone uses that helper for on-demand host acquisition. Tokens are not injected through askpass or environment variables; `GIT_ASKPASS=/bin/false` disables prompts. Provider miss permits anonymous public access; authentication failure points to host login or 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. @@ -288,7 +308,7 @@ Presentation / QUIET: [terminal-output.md](terminal-output.md) (picker stays raw | `up` create | Fresh bind-mode create when occupancy is none (desired name free, no same-workspace leftover) | | `up` reuse / start-stopped | Only when existing container's stamped config hash **equals** current resolve. Running → reuse (no Features re-run; host `initializeCommand` still runs); stopped → start + `postStartCommand` + feature remelt | | `up` config hash mismatch | Fail closed: `config_hash_mismatch`; **does not** delete or replace. Hint: `adevcontainer rebuild` (managed selection: `--name` or auto) | -| `rebuild` | **Forced rebuild** (landed; archive [`20260810-rebuild`](../../specs/changes/archive/20260810-rebuild/)): read current stamped config **before** any delete and capture the existing workspace repository’s local author identity (bind: host workspace; volume: old container workspace); hostRequirements + Features; then container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `adev-*-ws` and `${devcontainerId}` volumes (ensureVolume, never delete/replace workspace volume; no re-clone; name-indexed resume refuses a missing `*-ws`). After ownership (pre-start helper or post-start normal path) and credential-seeding work, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; missing/incomplete → no invented value; host Git config and other container users remain unchanged. Foreign occupant of the new name: do not delete selected; TTY Y/n + new name (persist into live config). `${devcontainerId}` expands to the resource stem (stable across create-name change). Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip; see [Lifecycle](#lifecycle-execution-hook-matrix)). Pre-delete failure leaves old container untouched. Optional `--skip-pull` / `--vscode` / `--json`. Volume `rebuild`: if any admitted ref is local-path, stage guest `.devcontainer` (and root `.devcontainer.json` when that is the config) via `exec tar` (not `container cp`) onto a host temp **before** Features/delete, then `DefaultFeatureFetcher`; fail structured pre-delete (old container kept); clean temp. Mixed local+OCI uses `DefaultFeatureFetcher`. OCI-only volume may still use `OCIFeatureClient`. Ownership: [Named volumes / ownership](#named-volumes-ensure--reuse--ownership) (volume-mode workspace chown only if connection user ≠ stamped `remote_user`). Hard post-delete recovery mode-split (TTY `Open the recovery editor now? [Y/n]` default Y; decline/EOF retain; named retry skips prompt; README + CLI help document UX): [gaps — Failed rebuild recovery](../domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: name-indexed state is published before delete; `rebuild --name` opens it on `containerNotFound` with no helper, reuses stamped `*-ws`, and does not create a blank workspace volume. A listed marked helper still wins. Bring-up (`up`/`clone`/`start`) is a separate primitive — [below](#bring-up-recovery-bringuprecovery) | +| `rebuild` | **Forced rebuild** (landed; archive [`20260810-rebuild`](../../specs/changes/archive/20260810-rebuild/)): read current stamped config **before** any delete and capture the existing workspace repository’s local author identity (bind: host workspace; volume: old container workspace); hostRequirements + Features; then container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `adev-*-ws` and `${devcontainerId}` volumes (ensureVolume, never delete/replace workspace volume; no re-clone; name-indexed resume refuses a missing `*-ws`). After ownership (pre-start helper or post-start normal path) and [credential connection setup](#automatic-git-credentials), synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; missing/incomplete → no invented value; host Git config and other container users remain unchanged. Foreign occupant of the new name: do not delete selected; TTY Y/n + new name (persist into live config). `${devcontainerId}` expands to the resource stem (stable across create-name change). Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip; see [Lifecycle](#lifecycle-execution-hook-matrix)). Pre-delete failure leaves old container untouched. Optional `--skip-pull` / `--vscode` / `--json`. Volume `rebuild`: if any admitted ref is local-path, stage guest `.devcontainer` (and root `.devcontainer.json` when that is the config) via `exec tar` (not `container cp`) onto a host temp **before** Features/delete, then `DefaultFeatureFetcher`; fail structured pre-delete (old container kept); clean temp. Mixed local+OCI uses `DefaultFeatureFetcher`. OCI-only volume may still use `OCIFeatureClient`. Ownership: [Named volumes / ownership](#named-volumes-ensure--reuse--ownership) (volume-mode workspace chown only if connection user ≠ stamped `remote_user`). Hard post-delete recovery mode-split (TTY `Open the recovery editor now? [Y/n]` default Y; decline/EOF retain; named retry skips prompt; README + CLI help document UX): [gaps — Failed rebuild recovery](../domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: name-indexed state is published before delete; `rebuild --name` opens it on `containerNotFound` with no helper, reuses stamped `*-ws`, and does not create a blank workspace volume. A listed marked helper still wins. Bring-up (`up`/`clone`/`start`) is a separate primitive — [below](#bring-up-recovery-bringuprecovery) | Nested `build` on `rebuild` (Dockerfile + Features rebuild, config-dir staging): [Dockerfile image](#dockerfile-image-nested-build). @@ -399,7 +419,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** — 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` | +| 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 [automatic Git credential setup](#automatic-git-credentials) (soft-fail); 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 |