From 485faecc390752c35472c75c659fe9890914f3e4 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 12 Sep 2026 00:52:29 +0530 Subject: [PATCH] feat: add secure origin-bound credential vault --- .github/scripts/macos-package.sh | 1 + .github/workflows/release.yml | 4 +- README.md | 18 + apps/headless/CredentialBroker/main.swift | 62 +++ .../CredentialMetadataStore.swift | 230 +++++++++++ .../CredentialVault.swift | 390 ++++++++++++++++++ .../LinuxSecretServiceCredentialStore.swift | 188 +++++++++ .../MacOSKeychainCredentialStore.swift | 92 +++++ .../PlatformCredentialStore.swift | 11 + apps/headless/Dockerfile.linux | 23 +- apps/headless/MCP/main.swift | 8 + apps/headless/Package.swift | 23 +- apps/headless/SecurePrompt/SecurePrompt.c | 261 ++++++++++++ .../include/CHeadlessSecurePrompt.h | 28 ++ apps/headless/Sources/HeadlessCLI/main.swift | 85 ++++ .../Sources/HeadlessProtocol/CLI.swift | 60 +++ .../HeadlessProtocol/Capabilities.swift | 21 + .../HeadlessProtocol/CredentialCommands.swift | 110 +++++ .../Tests/HeadlessMCPTests/main.swift | 35 +- .../HeadlessProtocolTests/ProtocolTests.swift | 238 +++++++++++ apps/headless/Tests/linux-credential-vault.sh | 80 ++++ apps/headless/Tests/linux-e2e.sh | 9 + apps/headless/Tests/linux-installer.sh | 7 +- apps/headless/Tests/macos-distribution.sh | 1 + apps/headless/Tests/secure-prompt.c | 237 +++++++++++ apps/headless/build-linux.sh | 5 +- apps/headless/build.sh | 7 + apps/headless/docs/COMMANDS.md | 47 +++ apps/headless/docs/P1.md | 21 + apps/headless/install-linux.sh | 8 +- apps/headless/install.sh | 1 + apps/headless/test.sh | 27 ++ packages/headless-npm/lib/installer.mjs | 10 +- packages/headless-npm/test/installer.test.mjs | 5 +- 34 files changed, 2332 insertions(+), 21 deletions(-) create mode 100644 apps/headless/CredentialBroker/main.swift create mode 100644 apps/headless/CredentialBrokerCore/CredentialMetadataStore.swift create mode 100644 apps/headless/CredentialBrokerCore/CredentialVault.swift create mode 100644 apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift create mode 100644 apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift create mode 100644 apps/headless/CredentialBrokerCore/PlatformCredentialStore.swift create mode 100644 apps/headless/SecurePrompt/SecurePrompt.c create mode 100644 apps/headless/SecurePrompt/include/CHeadlessSecurePrompt.h create mode 100644 apps/headless/Sources/HeadlessProtocol/CredentialCommands.swift create mode 100644 apps/headless/Tests/linux-credential-vault.sh create mode 100644 apps/headless/Tests/secure-prompt.c diff --git a/.github/scripts/macos-package.sh b/.github/scripts/macos-package.sh index 2be5e6e..38f5a38 100755 --- a/.github/scripts/macos-package.sh +++ b/.github/scripts/macos-package.sh @@ -36,6 +36,7 @@ package_app() { unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/MacOS/Headless' unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless' unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless-mcp' + unzip -Z1 "$ARCHIVE" | grep -qx 'Headless.app/Contents/Resources/bin/headless-credential-broker' } if [ "$MODE" = "--notarize" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a84e945..efe110b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -239,7 +239,7 @@ jobs: archive="apps/headless/build/headless-${VERSION}-linux-amd64.tar.gz" test -s "$archive" tar -tzf "$archive" > archive-contents.txt - for expected in headless headless-host headless-mcp install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do + for expected in headless headless-host headless-mcp headless-credential-broker install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do grep -qx "$expected" archive-contents.txt done - uses: actions/upload-artifact@v7 @@ -269,7 +269,7 @@ jobs: archive="apps/headless/build/headless-${VERSION}-linux-arm64.tar.gz" test -s "$archive" tar -tzf "$archive" > archive-contents.txt - for expected in headless headless-host headless-mcp install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do + for expected in headless headless-host headless-mcp headless-credential-broker install-linux.sh Headless_HeadlessProtocol.resources/AgentRuntime.js; do grep -qx "$expected" archive-contents.txt done - uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 1671e20..1f43f6a 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,24 @@ normal profile. Linux stores it in a private XDG data directory; macOS uses the persistent WebKit data store. Headless does not accept imported cookies or a caller-selected profile path. +Saved credentials use a dedicated local broker instead of the browser socket: + +```sh +headless credentials add --origin https://example.com --alias work --interactive +headless credentials list --origin https://example.com +headless credentials rename --origin https://example.com --alias work --to client +headless credentials remove --origin https://example.com --alias client +``` + +The interactive broker reads and confirms passwords only through the attached +terminal with echo disabled. Agents can see approved usernames and aliases, +but password values never enter arguments, MCP, browser commands, logs, flows, +or output. macOS uses the encrypted default user Keychain with a decrypt-only +ACL and reports the unsigned local security tier honestly. Linux requires an +available system Secret Service and never falls back to plaintext. This +increment manages vault records. Broker-owned user-presence checks, +origin-bound login challenges, and browser autofill follow in #157. + ## Agent skill This repository ships a portable browser-computer-use skill at diff --git a/apps/headless/CredentialBroker/main.swift b/apps/headless/CredentialBroker/main.swift new file mode 100644 index 0000000..386eba7 --- /dev/null +++ b/apps/headless/CredentialBroker/main.swift @@ -0,0 +1,62 @@ +import CredentialBrokerCore +import Foundation +import HeadlessProtocol + +private func printJSON(_ value: JSONValue) { + guard let data = try? ProtocolCodec.encoder.encode(value) else { + fputs("headless credential broker: output encoding failed\n", stderr) + exit(70) + } + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0A])) +} + +do { + let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst())) + guard case .credentials(let command)? = invocation.local, invocation.request == nil else { + throw CredentialCommandError.invalidArguments + } + let controller = CredentialVaultController( + metadata: CredentialMetadataStore(), + secrets: try makePlatformCredentialSecretStore() + ) + let result: JSONValue + switch command { + case .list(let origin): + result = try controller.list(origin: origin) + case .add(let origin, let alias): + result = try controller.add(origin: origin, alias: alias) + case .rename(let origin, let alias, let newAlias): + result = try controller.rename(origin: origin, alias: alias, to: newAlias) + case .remove(let origin, let alias): + result = try controller.remove(origin: origin, alias: alias) + } + printJSON(.object(["ok": .bool(true), "result": result])) +} catch let error as CredentialVaultError { + printJSON(.object([ + "ok": .bool(false), + "error": .object(["code": .string(error.code), "message": .string(error.description)]), + ])) + exit(error == .terminalRequired ? 64 : 69) +} catch let error as CredentialCommandError { + printJSON(.object([ + "ok": .bool(false), + "error": .object(["code": .string("INVALID_CREDENTIAL_COMMAND"), "message": .string(error.description)]), + ])) + exit(64) +} catch let error as CLIParseError { + printJSON(.object([ + "ok": .bool(false), + "error": .object(["code": .string("INVALID_CREDENTIAL_COMMAND"), "message": .string(error.description)]), + ])) + exit(64) +} catch { + printJSON(.object([ + "ok": .bool(false), + "error": .object([ + "code": .string("VAULT_OPERATION_FAILED"), + "message": .string("Credential vault operation failed without exposing sensitive details."), + ]), + ])) + exit(70) +} diff --git a/apps/headless/CredentialBrokerCore/CredentialMetadataStore.swift b/apps/headless/CredentialBrokerCore/CredentialMetadataStore.swift new file mode 100644 index 0000000..aeba206 --- /dev/null +++ b/apps/headless/CredentialBrokerCore/CredentialMetadataStore.swift @@ -0,0 +1,230 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum CredentialTransactionKind: String, Codable, Sendable { + case add + case remove +} + +public struct CredentialPendingTransaction: Codable, Equatable, Sendable { + public let kind: CredentialTransactionKind + public let record: CredentialRecord + + public init(kind: CredentialTransactionKind, record: CredentialRecord) { + self.kind = kind + self.record = record + } +} + +public struct CredentialMetadataState: Equatable, Sendable { + public var records: [CredentialRecord] + public var pending: [CredentialPendingTransaction] + + public init( + records: [CredentialRecord] = [], pending: [CredentialPendingTransaction] = [] + ) { + self.records = records + self.pending = pending + } +} + +public final class CredentialMetadataTransaction { + public var state: CredentialMetadataState + private let persist: (CredentialMetadataState) throws -> Void + + fileprivate init( + state: CredentialMetadataState, + persist: @escaping (CredentialMetadataState) throws -> Void + ) { + self.state = state + self.persist = persist + } + + public func save() throws { + try persist(state) + } +} + +private struct CredentialIndex: Codable { + let schemaVersion: Int + let records: [CredentialRecord] + let pending: [CredentialPendingTransaction]? +} + +public final class CredentialMetadataStore: @unchecked Sendable { + public static let maximumIndexBytes = 1_048_576 + + public let rootURL: URL + private let indexURL: URL + private let lockURL: URL + + public init( + rootURL: URL = CredentialMetadataStore.defaultRootURL() + ) { + self.rootURL = rootURL.standardizedFileURL + indexURL = self.rootURL.appendingPathComponent("credentials-index.json") + lockURL = self.rootURL.appendingPathComponent("credentials-index.lock") + } + + public static func defaultRootURL() -> URL { + let home = FileManager.default.homeDirectoryForCurrentUser + #if os(macOS) + return home.appendingPathComponent( + "Library/Application Support/com.headless.app/credential-vault", isDirectory: true + ) + #else + let base = home.appendingPathComponent(".local/share", isDirectory: true) + return base.appendingPathComponent("headless/credential-vault", isDirectory: true) + #endif + } + + public func withLockedState( + _ body: (CredentialMetadataTransaction) throws -> T + ) throws -> T { + try preparePrivateDirectory(rootURL) + let lock = open(lockURL.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard lock >= 0 else { throw CredentialVaultError.insecureMetadata } + defer { close(lock) } + try validatePrivateRegularFile(lock) + guard flock(lock, LOCK_EX) == 0 else { + throw CredentialVaultError.operationFailed("metadata lock") + } + defer { _ = flock(lock, LOCK_UN) } + + let transaction = CredentialMetadataTransaction( + state: try readState(), persist: { [self] in try writeState($0) } + ) + return try body(transaction) + } + + private func readState() throws -> CredentialMetadataState { + let descriptor = open(indexURL.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + if descriptor < 0 { + if errno == ENOENT { return CredentialMetadataState() } + throw CredentialVaultError.insecureMetadata + } + defer { close(descriptor) } + try validatePrivateRegularFile(descriptor) + + var info = stat() + guard fstat(descriptor, &info) == 0, info.st_size >= 0, + info.st_size <= Self.maximumIndexBytes else { + throw CredentialVaultError.corruptMetadata + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16_384) + while true { + let count = read(descriptor, &buffer, buffer.count) + if count < 0 && errno == EINTR { continue } + guard count >= 0 else { throw CredentialVaultError.operationFailed("metadata read") } + if count == 0 { break } + data.append(buffer, count: count) + guard data.count <= Self.maximumIndexBytes else { throw CredentialVaultError.corruptMetadata } + } + guard let index = try? JSONDecoder().decode(CredentialIndex.self, from: data), + index.schemaVersion == 1 else { + throw CredentialVaultError.corruptMetadata + } + let pending = index.pending ?? [] + let allRecords = index.records + pending.map(\.record) + guard allRecords.count <= CredentialVaultController.maximumRecords, + Set(allRecords.map(\.id)).count == allRecords.count, + Set(allRecords.map { + "\($0.origin.rawValue)\u{0}\($0.alias.rawValue.lowercased())" + }).count + == allRecords.count else { + throw CredentialVaultError.corruptMetadata + } + return CredentialMetadataState(records: index.records, pending: pending) + } + + private func writeState(_ state: CredentialMetadataState) throws { + let allRecords = state.records + state.pending.map(\.record) + guard allRecords.count <= CredentialVaultController.maximumRecords, + Set(allRecords.map(\.id)).count == allRecords.count, + Set(allRecords.map { + "\($0.origin.rawValue)\u{0}\($0.alias.rawValue.lowercased())" + }).count == allRecords.count else { + throw CredentialVaultError.capacityExceeded + } + let data = try JSONEncoder.headlessCredentialEncoder.encode( + CredentialIndex(schemaVersion: 1, records: state.records, pending: state.pending) + ) + guard data.count <= Self.maximumIndexBytes else { throw CredentialVaultError.capacityExceeded } + let temporary = rootURL.appendingPathComponent(".credentials-index.tmp-\(UUID().uuidString)") + let descriptor = open( + temporary.path, O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC | O_NOFOLLOW, 0o600 + ) + guard descriptor >= 0 else { throw CredentialVaultError.operationFailed("metadata creation") } + var shouldRemove = true + defer { + close(descriptor) + if shouldRemove { unlink(temporary.path) } + } + try validatePrivateRegularFile(descriptor) + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let count = write(descriptor, base.advanced(by: offset), bytes.count - offset) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { throw CredentialVaultError.operationFailed("metadata write") } + offset += count + } + } + guard fsync(descriptor) == 0 else { throw CredentialVaultError.operationFailed("metadata sync") } + guard rename(temporary.path, indexURL.path) == 0 else { + throw CredentialVaultError.operationFailed("metadata activation") + } + shouldRemove = false + let directory = open(rootURL.path, O_RDONLY | O_CLOEXEC) + if directory >= 0 { + _ = fsync(directory) + close(directory) + } + } + + private func preparePrivateDirectory(_ url: URL) throws { + let parent = url.deletingLastPathComponent() + if parent.path != url.path, !FileManager.default.fileExists(atPath: parent.path) { + try preparePrivateDirectory(parent) + } + var info = stat() + if lstat(url.path, &info) == 0 { + guard (info.st_mode & S_IFMT) == S_IFDIR, info.st_uid == getuid(), + (info.st_mode & 0o077) == 0 else { + throw CredentialVaultError.insecureMetadata + } + return + } + guard errno == ENOENT, mkdir(url.path, 0o700) == 0 || errno == EEXIST else { + throw CredentialVaultError.operationFailed("metadata directory creation") + } + guard chmod(url.path, 0o700) == 0 else { + throw CredentialVaultError.operationFailed("metadata directory permissions") + } + } + + private func validatePrivateRegularFile(_ descriptor: Int32) throws { + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), (info.st_mode & 0o077) == 0 else { + throw CredentialVaultError.insecureMetadata + } + guard fchmod(descriptor, 0o600) == 0 else { + throw CredentialVaultError.operationFailed("metadata permissions") + } + } +} + +private extension JSONEncoder { + static let headlessCredentialEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() +} diff --git a/apps/headless/CredentialBrokerCore/CredentialVault.swift b/apps/headless/CredentialBrokerCore/CredentialVault.swift new file mode 100644 index 0000000..38a8f2b --- /dev/null +++ b/apps/headless/CredentialBrokerCore/CredentialVault.swift @@ -0,0 +1,390 @@ +import CHeadlessSecurePrompt +import Foundation +import HeadlessProtocol +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public struct CredentialRecord: Codable, Equatable, Sendable { + public let id: String + public let origin: CredentialOrigin + public let alias: CredentialAlias + public let account: String + public let createdAt: Double + + public init( + id: String = UUID().uuidString.lowercased(), + origin: CredentialOrigin, + alias: CredentialAlias, + account: String, + createdAt: Double = Date().timeIntervalSince1970 + ) throws { + guard UUID(uuidString: id) != nil else { throw CredentialVaultError.corruptMetadata } + self.id = id.lowercased() + self.origin = origin + self.alias = alias + self.account = try validatedCredentialAccount(account) + self.createdAt = createdAt + } + + private enum CodingKeys: String, CodingKey { case id, origin, alias, account, createdAt } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + id: container.decode(String.self, forKey: .id), + origin: container.decode(CredentialOrigin.self, forKey: .origin), + alias: container.decode(CredentialAlias.self, forKey: .alias), + account: container.decode(String.self, forKey: .account), + createdAt: container.decode(Double.self, forKey: .createdAt) + ) + } + + public var publicValue: JSONValue { + .object([ + "origin": .string(origin.rawValue), + "alias": .string(alias.rawValue), + "username": .string(account), + ]) + } +} + +public func validatedCredentialAccount(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.utf8.count <= 320, + !trimmed.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else { + throw CredentialVaultError.invalidAccount + } + return trimmed +} + +public enum CredentialVaultError: Error, Equatable, CustomStringConvertible { + case invalidAccount + case duplicateAlias + case notFound + case capacityExceeded + case vaultUnavailable + case vaultLocked + case userDenied + case corruptMetadata + case insecureMetadata + case terminalRequired + case promptFailed + case operationFailed(String) + + public var code: String { + switch self { + case .invalidAccount: return "INVALID_ACCOUNT" + case .duplicateAlias: return "CREDENTIAL_ALIAS_EXISTS" + case .notFound: return "CREDENTIAL_NOT_FOUND" + case .capacityExceeded: return "CREDENTIAL_LIMIT_REACHED" + case .vaultUnavailable: return "VAULT_UNAVAILABLE" + case .vaultLocked: return "VAULT_LOCKED" + case .userDenied: return "USER_PRESENCE_DENIED" + case .corruptMetadata: return "VAULT_METADATA_CORRUPT" + case .insecureMetadata: return "VAULT_METADATA_INSECURE" + case .terminalRequired: return "SECURE_TERMINAL_REQUIRED" + case .promptFailed: return "SECURE_PROMPT_FAILED" + case .operationFailed: return "VAULT_OPERATION_FAILED" + } + } + + public var description: String { + switch self { + case .invalidAccount: + return "Account identity must be 1-320 characters without control characters." + case .duplicateAlias: + return "That credential alias already exists for this origin." + case .notFound: + return "No credential matches that exact origin and alias." + case .capacityExceeded: + return "The credential vault has reached its 1,000-record limit." + case .vaultUnavailable: + return "An approved operating-system credential vault is unavailable." + case .vaultLocked: + return "The operating-system credential vault is locked." + case .userDenied: + return "The user denied credential-vault authorization." + case .corruptMetadata: + return "Credential index metadata is corrupt or uses an unsupported schema." + case .insecureMetadata: + return "Credential index metadata is not a private regular file owned by this user." + case .terminalRequired: + return "Interactive credential entry requires an attached terminal; piped input is rejected." + case .promptFailed: + return "Secure terminal input failed. Terminal echo was restored." + case .operationFailed(let operation): + return "Credential vault operation failed: \(operation)." + } + } +} + +public final class SensitiveBytes: @unchecked Sendable { + private var storage: [UInt8] + + public init(_ bytes: [UInt8]) { + storage = bytes + } + + deinit { clear() } + + public var isEmpty: Bool { storage.isEmpty } + + public func withUnsafeBytes(_ body: (UnsafeRawBufferPointer) throws -> T) rethrows -> T { + try storage.withUnsafeBytes(body) + } + + public func matches(_ other: SensitiveBytes) -> Bool { + guard storage.count == other.storage.count else { return false } + var difference: UInt8 = 0 + for index in storage.indices { difference |= storage[index] ^ other.storage[index] } + return difference == 0 + } + + public func clear() { + storage.withUnsafeMutableBytes { buffer in + guard let base = buffer.baseAddress else { return } + headless_secure_clear(base.assumingMemoryBound(to: UInt8.self), buffer.count) + } + storage.removeAll(keepingCapacity: false) + } +} + +public protocol CredentialSecretStore { + var backendName: String { get } + func store(_ secret: SensitiveBytes, for record: CredentialRecord) throws + func remove(recordID: String) throws +} + +public protocol CredentialPrompting { + func readAccount() throws -> String + func readPassword() throws -> SensitiveBytes + func readPasswordConfirmation() throws -> SensitiveBytes +} + +public struct SecureTerminalPrompt: CredentialPrompting { + public init() {} + + public func readAccount() throws -> String { + try validatedCredentialAccount(read(prompt: "Account username/email: ", hidden: false, maximum: 320)) + } + + public func readPassword() throws -> SensitiveBytes { + SensitiveBytes(Array(try readBytes(prompt: "Password: ", hidden: true, maximum: 4_096))) + } + + public func readPasswordConfirmation() throws -> SensitiveBytes { + SensitiveBytes(Array(try readBytes( + prompt: "Confirm password: ", hidden: true, maximum: 4_096 + ))) + } + + private func read(prompt: String, hidden: Bool, maximum: Int) throws -> String { + let bytes = try readBytes(prompt: prompt, hidden: hidden, maximum: maximum) + guard let value = String(bytes: bytes, encoding: .utf8) else { + throw CredentialVaultError.promptFailed + } + return value + } + + private func readBytes(prompt: String, hidden: Bool, maximum: Int) throws -> [UInt8] { + var pointer: UnsafeMutablePointer? + var count = 0 + let result = prompt.withCString { + headless_read_tty_line($0, hidden ? 1 : 0, &pointer, &count) + } + guard result == Int32(HEADLESS_PROMPT_SUCCESS.rawValue), let pointer else { + if result == Int32(HEADLESS_PROMPT_NOT_TTY.rawValue) + || result == Int32(HEADLESS_PROMPT_OPEN_FAILED.rawValue) + || result == Int32(HEADLESS_PROMPT_NOT_FOREGROUND.rawValue) { + throw CredentialVaultError.terminalRequired + } + throw CredentialVaultError.promptFailed + } + defer { headless_clear_and_free(pointer, count + 1) } + guard count <= maximum else { throw CredentialVaultError.promptFailed } + return Array(UnsafeBufferPointer(start: pointer, count: count)) + } +} + +public final class CredentialVaultController { + public static let maximumRecords = 1_000 + public static let maximumListedRecords = 500 + + private let metadata: CredentialMetadataStore + private let secrets: CredentialSecretStore + private let prompt: CredentialPrompting + + public init( + metadata: CredentialMetadataStore, + secrets: CredentialSecretStore, + prompt: CredentialPrompting = SecureTerminalPrompt() + ) { + self.metadata = metadata + self.secrets = secrets + self.prompt = prompt + } + + public func list(origin: CredentialOrigin?) throws -> JSONValue { + try withRecoveredState { transaction in + let matching = transaction.state.records.filter { origin == nil || $0.origin == origin } + .sorted { ($0.origin.rawValue, $0.alias.rawValue) < ($1.origin.rawValue, $1.alias.rawValue) } + let listed = Array(matching.prefix(Self.maximumListedRecords)) + return .object([ + "credentials": .array(listed.map(\.publicValue)), + "total": .number(Double(matching.count)), + "omitted": .number(Double(matching.count - listed.count)), + "truncated": .bool(listed.count < matching.count), + "passwordsExposed": .bool(false), + ]) + } + } + + public func add(origin: CredentialOrigin, alias: CredentialAlias) throws -> JSONValue { + try withRecoveredState { transaction in + guard transaction.state.records.count < Self.maximumRecords else { + throw CredentialVaultError.capacityExceeded + } + guard !transaction.state.records.contains(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { throw CredentialVaultError.duplicateAlias } + } + let account = try prompt.readAccount() + let secret = try prompt.readPassword() + defer { secret.clear() } + guard !secret.isEmpty else { throw CredentialVaultError.promptFailed } + let confirmation = try prompt.readPasswordConfirmation() + defer { confirmation.clear() } + guard secret.matches(confirmation) else { + throw CredentialVaultError.operationFailed("password confirmation did not match") + } + + return try withRecoveredState { transaction in + guard transaction.state.records.count < Self.maximumRecords else { + throw CredentialVaultError.capacityExceeded + } + guard !transaction.state.records.contains(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { + throw CredentialVaultError.duplicateAlias + } + let record = try CredentialRecord(origin: origin, alias: alias, account: account) + let pending = CredentialPendingTransaction(kind: .add, record: record) + transaction.state.pending.append(pending) + try transaction.save() + do { + try secrets.store(secret, for: record) + } catch { + do { + try secrets.remove(recordID: record.id) + transaction.state.pending.removeAll { $0.record.id == record.id } + try transaction.save() + } catch { throw CredentialVaultError.operationFailed("add rollback") } + throw error + } + transaction.state.records.append(record) + transaction.state.pending.removeAll { $0.record.id == record.id } + do { + try transaction.save() + } catch { + do { + try secrets.remove(recordID: record.id) + transaction.state.records.removeAll { $0.id == record.id } + try transaction.save() + } catch { throw CredentialVaultError.operationFailed("add rollback") } + throw error + } + return .object([ + "saved": .bool(true), + "credential": record.publicValue, + "backend": .string(secrets.backendName), + "passwordExposed": .bool(false), + ]) + } + } + + public func rename( + origin: CredentialOrigin, alias: CredentialAlias, to newAlias: CredentialAlias + ) throws -> JSONValue { + try withRecoveredState { transaction in + guard let index = transaction.state.records.firstIndex(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { + throw CredentialVaultError.notFound + } + guard alias.rawValue.caseInsensitiveCompare(newAlias.rawValue) == .orderedSame + || !transaction.state.records.contains(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(newAlias.rawValue) == .orderedSame + }) else { throw CredentialVaultError.duplicateAlias } + if alias.rawValue == newAlias.rawValue { + return .object([ + "renamed": .bool(false), "credential": transaction.state.records[index].publicValue, + ]) + } + let previous = transaction.state.records[index] + let renamed = try CredentialRecord( + id: previous.id, origin: previous.origin, alias: newAlias, + account: previous.account, createdAt: previous.createdAt + ) + transaction.state.records[index] = renamed + try transaction.save() + return .object(["renamed": .bool(true), "credential": renamed.publicValue]) + } + } + + public func remove(origin: CredentialOrigin, alias: CredentialAlias) throws -> JSONValue { + try withRecoveredState { transaction in + guard let index = transaction.state.records.firstIndex(where: { + $0.origin == origin + && $0.alias.rawValue.caseInsensitiveCompare(alias.rawValue) == .orderedSame + }) else { + throw CredentialVaultError.notFound + } + let previous = transaction.state + let removed = transaction.state.records.remove(at: index) + transaction.state.pending.append( + CredentialPendingTransaction(kind: .remove, record: removed) + ) + try transaction.save() + do { + try secrets.remove(recordID: removed.id) + } catch { + do { + transaction.state = previous + try transaction.save() + } + catch { throw CredentialVaultError.operationFailed("delete rollback") } + throw error + } + transaction.state.pending.removeAll { $0.record.id == removed.id } + try transaction.save() + return .object([ + "removed": .bool(true), + "origin": .string(origin.rawValue), + "alias": .string(alias.rawValue), + ]) + } + } + + private func withRecoveredState( + _ body: (CredentialMetadataTransaction) throws -> T + ) throws -> T { + try metadata.withLockedState { transaction in + while let pending = transaction.state.pending.first { + switch pending.kind { + case .add, .remove: + try secrets.remove(recordID: pending.record.id) + } + transaction.state.pending.removeFirst() + try transaction.save() + } + return try body(transaction) + } + } +} diff --git a/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift b/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift new file mode 100644 index 0000000..0120846 --- /dev/null +++ b/apps/headless/CredentialBrokerCore/LinuxSecretServiceCredentialStore.swift @@ -0,0 +1,188 @@ +#if os(Linux) +import Dispatch +import Foundation +import Glibc + +public final class LinuxSecretServiceCredentialStore: CredentialSecretStore { + public let backendName = "linux-secret-service" + private let executableURL: URL + private let runtimeDirectory: String + private let busAddress: String + + public init() throws { + guard let executable = Self.approvedExecutable() else { + throw CredentialVaultError.vaultUnavailable + } + executableURL = executable + guard let sessionBus = Self.validatedSessionBus() else { + throw CredentialVaultError.vaultUnavailable + } + runtimeDirectory = sessionBus.runtimeDirectory + busAddress = sessionBus.address + } + + public func store(_ secret: SensitiveBytes, for record: CredentialRecord) throws { + try run([ + "store", "--label=Headless saved credential", + "application", "com.headless.credentials.v1", "credential-id", record.id, + ], secret: secret) + } + + public func remove(recordID: String) throws { + try run([ + "clear", "application", "com.headless.credentials.v1", "credential-id", recordID, + ], secret: nil) + } + + private func run(_ arguments: [String], secret: SensitiveBytes?) throws { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.environment = Self.sanitizedEnvironment( + ProcessInfo.processInfo.environment, + runtimeDirectory: runtimeDirectory, + busAddress: busAddress + ) + process.standardOutput = FileHandle.nullDevice + let errorPipe = Pipe() + let errorCapture = BoundedErrorCapture() + process.standardError = errorPipe + errorCapture.start(reading: errorPipe.fileHandleForReading) + let input = Pipe() + process.standardInput = input + let completion = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in completion.signal() } + do { + try process.run() + } catch { + errorPipe.fileHandleForWriting.closeFile() + _ = errorCapture.text() + throw CredentialVaultError.vaultUnavailable + } + errorPipe.fileHandleForWriting.closeFile() + + if let secret { + do { + try secret.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let count = Glibc.write( + input.fileHandleForWriting.fileDescriptor, + base.advanced(by: offset), bytes.count - offset + ) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { + throw CredentialVaultError.operationFailed("Secret Service input") + } + offset += count + } + } + } catch { + input.fileHandleForWriting.closeFile() + process.terminate() + process.waitUntilExit() + _ = errorCapture.text() + throw CredentialVaultError.operationFailed("Secret Service input") + } + } + input.fileHandleForWriting.closeFile() + guard completion.wait(timeout: .now() + 15) == .success else { + if process.isRunning { process.terminate() } + if completion.wait(timeout: .now() + 2) == .timedOut, process.isRunning { + _ = kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + _ = errorCapture.text() + throw CredentialVaultError.operationFailed("Secret Service timeout") + } + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + throw Self.classifiedBackendError(errorCapture.text()) + } + _ = errorCapture.text() + } + + private static func approvedExecutable() -> URL? { + for path in ["/usr/bin/secret-tool"] { + var info = stat() + guard lstat(path, &info) == 0 else { continue } + let safeMode = (info.st_mode & 0o022) == 0 + if (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == 0, safeMode, + access(path, X_OK) == 0 { + return URL(fileURLWithPath: path) + } + } + return nil + } + + private static func validatedSessionBus() -> (runtimeDirectory: String, address: String)? { + let runtimeDirectory = "/run/user/\(getuid())" + let socketPath = "\(runtimeDirectory)/bus" + var directoryInfo = stat() + var socketInfo = stat() + guard lstat(runtimeDirectory, &directoryInfo) == 0, + (directoryInfo.st_mode & S_IFMT) == S_IFDIR, + directoryInfo.st_uid == getuid(), (directoryInfo.st_mode & 0o077) == 0, + lstat(socketPath, &socketInfo) == 0, + (socketInfo.st_mode & S_IFMT) == S_IFSOCK, + socketInfo.st_uid == getuid() else { + return nil + } + return (runtimeDirectory, "unix:path=\(socketPath)") + } + + private static func sanitizedEnvironment( + _ source: [String: String], runtimeDirectory: String, busAddress: String + ) -> [String: String] { + let exact = [ + "HOME", "USER", "LOGNAME", "DISPLAY", "WAYLAND_DISPLAY", + ] + var result = source.filter { exact.contains($0.key) } + result["PATH"] = "/usr/bin:/bin" + result["LANG"] = "C" + result["XDG_RUNTIME_DIR"] = runtimeDirectory + result["DBUS_SESSION_BUS_ADDRESS"] = busAddress + return result + } + + private static func classifiedBackendError(_ text: String) -> CredentialVaultError { + let normalized = text.lowercased() + if normalized.contains("locked") { return .vaultLocked } + if normalized.contains("denied") || normalized.contains("dismissed") + || normalized.contains("cancelled") || normalized.contains("canceled") + || normalized.contains("permission") { + return .userDenied + } + return .vaultUnavailable + } +} + +private final class BoundedErrorCapture: @unchecked Sendable { + private static let maximumBytes = 8_192 + private let group = DispatchGroup() + private let lock = NSLock() + private var bytes: [UInt8] = [] + + func start(reading handle: FileHandle) { + group.enter() + DispatchQueue.global(qos: .utility).async { [self] in + defer { group.leave() } + while true { + let data = handle.readData(ofLength: 4_096) + if data.isEmpty { return } + lock.lock() + let remaining = max(0, Self.maximumBytes - bytes.count) + bytes.append(contentsOf: data.prefix(remaining)) + lock.unlock() + } + } + } + + func text() -> String { + group.wait() + lock.lock() + defer { lock.unlock() } + return String(decoding: bytes, as: UTF8.self) + } +} +#endif diff --git a/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift b/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift new file mode 100644 index 0000000..637b403 --- /dev/null +++ b/apps/headless/CredentialBrokerCore/MacOSKeychainCredentialStore.swift @@ -0,0 +1,92 @@ +#if os(macOS) +import Foundation +import Security + +public final class MacOSKeychainCredentialStore: CredentialSecretStore { + private static let service = "com.headless.credentials.v1" + + public let backendName = "macos-login-keychain" + + public init() throws { + var result: CFTypeRef? + let status = SecItemCopyMatching([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + ] as CFDictionary, &result) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw mappedKeychainError(status) + } + } + + public func store(_ secret: SensitiveBytes, for record: CredentialRecord) throws { + let access = try passwordProtectedAccess() + var password = secret.withUnsafeBytes { Data($0) } + defer { password.resetBytes(in: 0.. [String: Any] { + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: recordID, + ] + } + + private func passwordProtectedAccess() throws -> SecAccess { + let label = "Headless saved credential" as CFString + let trustedApplications = [] as CFArray + var access: SecAccess? + let createStatus = SecAccessCreate(label, trustedApplications, &access) + guard createStatus == errSecSuccess, let access else { + throw mappedKeychainError(createStatus) + } + var aclList: CFArray? + let listStatus = SecAccessCopyACLList(access, &aclList) + guard listStatus == errSecSuccess, let acls = aclList as? [SecACL], !acls.isEmpty else { + throw mappedKeychainError(listStatus) + } + guard let decryptACL = acls.first(where: { acl in + let authorizations = SecACLCopyAuthorizations(acl) as? [String] ?? [] + return authorizations.contains(kSecACLAuthorizationDecrypt as String) + }) else { + throw CredentialVaultError.operationFailed("Keychain decrypt ACL") + } + let status = SecACLSetContents( + decryptACL, trustedApplications, label, [.requirePassphase] + ) + guard status == errSecSuccess else { throw mappedKeychainError(status) } + return access + } +} + +private func mappedKeychainError(_ status: OSStatus) -> CredentialVaultError { + switch status { + case errSecDuplicateItem: return .duplicateAlias + case errSecItemNotFound: return .notFound + case errSecUserCanceled: return .userDenied + case errSecAuthFailed: return .userDenied + case errSecInteractionNotAllowed: return .vaultLocked + case errSecNotAvailable, errSecNoDefaultKeychain: return .vaultUnavailable + case errSecMissingEntitlement: return .vaultUnavailable + default: return .operationFailed("Keychain status \(status)") + } +} +#endif diff --git a/apps/headless/CredentialBrokerCore/PlatformCredentialStore.swift b/apps/headless/CredentialBrokerCore/PlatformCredentialStore.swift new file mode 100644 index 0000000..1ec6297 --- /dev/null +++ b/apps/headless/CredentialBrokerCore/PlatformCredentialStore.swift @@ -0,0 +1,11 @@ +import Foundation + +public func makePlatformCredentialSecretStore() throws -> CredentialSecretStore { + #if os(macOS) + return try MacOSKeychainCredentialStore() + #elseif os(Linux) + return try LinuxSecretServiceCredentialStore() + #else + throw CredentialVaultError.vaultUnavailable + #endif +} diff --git a/apps/headless/Dockerfile.linux b/apps/headless/Dockerfile.linux index c459bcd..520be0b 100644 --- a/apps/headless/Dockerfile.linux +++ b/apps/headless/Dockerfile.linux @@ -8,6 +8,9 @@ COPY VersionSupport ./VersionSupport COPY main.swift ./ COPY Host ./Host COPY Sources ./Sources +COPY CredentialBroker ./CredentialBroker +COPY CredentialBrokerCore ./CredentialBrokerCore +COPY SecurePrompt ./SecurePrompt COPY LinuxHost ./LinuxHost COPY MCP ./MCP COPY Tests ./Tests @@ -20,25 +23,30 @@ RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ && ./.build/release/headless-protocol-tests RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ swift build -c release --static-swift-stdlib --product headless +RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ + swift build -c release --static-swift-stdlib --product headless-credential-broker RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ swift build -c release --static-swift-stdlib --product headless-linux-host RUN HEADLESS_VERSION="${HEADLESS_BUILD_VERSION:-$(cat VERSION)}" \ swift build -c release --static-swift-stdlib --product headless-mcp -RUN strip --strip-unneeded .build/release/headless .build/release/headless-linux-host .build/release/headless-mcp +RUN strip --strip-unneeded .build/release/headless .build/release/headless-linux-host \ + .build/release/headless-mcp .build/release/headless-credential-broker FROM debian:bookworm-slim AS runtime-base LABEL org.opencontainers.image.source="https://github.com/LockInTime/headless" \ org.opencontainers.image.description="Persistent safety-enforced browser control for AI agents" \ org.opencontainers.image.licenses="MIT" RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates chromium chromium-sandbox ffmpeg \ + && apt-get install -y --no-install-recommends ca-certificates chromium chromium-sandbox ffmpeg libsecret-tools \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --uid 10001 headless COPY --from=builder /src/.build/release/headless /usr/local/bin/headless COPY --from=builder /src/.build/release/headless-linux-host /usr/local/bin/headless-host COPY --from=builder /src/.build/release/headless-mcp /usr/local/bin/headless-mcp +COPY --from=builder /src/.build/release/headless-credential-broker /usr/local/bin/headless-credential-broker COPY --from=builder /src/.build/release/Headless_HeadlessProtocol.resources /usr/local/bin/Headless_HeadlessProtocol.resources -RUN chmod 0755 /usr/local/bin/headless /usr/local/bin/headless-host /usr/local/bin/headless-mcp +RUN chmod 0755 /usr/local/bin/headless /usr/local/bin/headless-host /usr/local/bin/headless-mcp \ + /usr/local/bin/headless-credential-broker USER headless ENV HEADLESS_HOST_EXECUTABLE=/usr/local/bin/headless-host ENV HEADLESS_CHROMIUM_EXECUTABLE=/usr/lib/chromium/chromium @@ -48,21 +56,26 @@ CMD ["/usr/local/bin/headless", "help"] FROM runtime-base AS test USER root RUN apt-get update \ - && apt-get install -y --no-install-recommends busybox \ + && apt-get install -y --no-install-recommends busybox expect gnome-keyring \ && rm -rf /var/lib/apt/lists/* +RUN install -d -m 0700 -o headless -g headless /run/user/10001 COPY Tests/Fixtures /opt/headless/fixtures COPY Tests/linux-e2e.sh /opt/headless/linux-e2e.sh +COPY Tests/linux-credential-vault.sh /opt/headless/linux-credential-vault.sh COPY Tests/conformance.sh /opt/headless/conformance.sh COPY install-linux.sh /opt/headless/package/install-linux.sh COPY --from=builder /src/.build/release/headless /opt/headless/package/headless COPY --from=builder /src/.build/release/headless-linux-host /opt/headless/package/headless-host COPY --from=builder /src/.build/release/headless-mcp /opt/headless/package/headless-mcp +COPY --from=builder /src/.build/release/headless-credential-broker /opt/headless/package/headless-credential-broker COPY --from=builder /src/.build/release/Headless_HeadlessProtocol.resources /opt/headless/package/Headless_HeadlessProtocol.resources -RUN chmod 0755 /opt/headless/linux-e2e.sh /opt/headless/conformance.sh \ +RUN chmod 0755 /opt/headless/linux-e2e.sh /opt/headless/linux-credential-vault.sh \ + /opt/headless/conformance.sh \ /opt/headless/package/install-linux.sh \ /opt/headless/package/headless \ /opt/headless/package/headless-host \ /opt/headless/package/headless-mcp \ + /opt/headless/package/headless-credential-broker \ && chown -R headless:headless /opt/headless USER headless diff --git a/apps/headless/MCP/main.swift b/apps/headless/MCP/main.swift index a977edd..aa3004e 100644 --- a/apps/headless/MCP/main.swift +++ b/apps/headless/MCP/main.swift @@ -68,6 +68,14 @@ while let line = readLine() { } do { let invocation = try CLIParser().parse(argv) + if case .credentials = invocation.local { + toolResult( + id: id, + text: "Credential commands require direct local user interaction and are unavailable over MCP.", + isError: true + ) + continue + } guard let command = invocation.request else { toolResult(id: id, text: "MCP accepts browser commands only; run `headless start` on the VM first.", isError: true) continue diff --git a/apps/headless/Package.swift b/apps/headless/Package.swift index e4a00c3..ddddd0e 100644 --- a/apps/headless/Package.swift +++ b/apps/headless/Package.swift @@ -22,6 +22,7 @@ let package = Package( .executable(name: "headless", targets: ["HeadlessCLI"]), .executable(name: "headless-host", targets: ["HeadlessHost"]), .executable(name: "headless-linux-host", targets: ["HeadlessLinuxHost"]), + .executable(name: "headless-credential-broker", targets: ["HeadlessCredentialBroker"]), .executable(name: "headless-mcp", targets: ["HeadlessMCP"]), .executable(name: "headless-mcp-tests", targets: ["HeadlessMCPTests"]), .executable(name: "headless-protocol-tests", targets: ["HeadlessProtocolTests"]), @@ -40,10 +41,28 @@ let package = Package( dependencies: ["CHeadlessVersion"], resources: [.process("Resources")] ), + .target( + name: "CHeadlessSecurePrompt", + path: "SecurePrompt", + publicHeadersPath: "include" + ), + .target( + name: "CredentialBrokerCore", + dependencies: ["HeadlessProtocol", "CHeadlessSecurePrompt"], + path: "CredentialBrokerCore", + linkerSettings: [ + .linkedFramework("Security", .when(platforms: [.macOS])), + ] + ), .executableTarget( name: "HeadlessCLI", dependencies: ["HeadlessProtocol"] ), + .executableTarget( + name: "HeadlessCredentialBroker", + dependencies: ["CredentialBrokerCore", "HeadlessProtocol"], + path: "CredentialBroker" + ), .executableTarget( name: "HeadlessLinuxHost", dependencies: ["HeadlessProtocol"], @@ -62,7 +81,7 @@ let package = Package( "Package.swift", "Sources", "Tests", "tools", "VersionSupport", "VERSION", "build.sh", "package.json", "headless.entitlements", "build", "docs", "test.sh", "LinuxHost", "Dockerfile.linux", "Headless.app", "build-linux.sh", "install.sh", "install-linux.sh", "benchmark.sh", ".dockerignore", - "MCP", "node_modules", + "MCP", "CredentialBroker", "CredentialBrokerCore", "SecurePrompt", "node_modules", ], sources: ["main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift"], linkerSettings: [ @@ -73,7 +92,7 @@ let package = Package( ), .executableTarget( name: "HeadlessProtocolTests", - dependencies: ["HeadlessProtocol"], + dependencies: ["HeadlessProtocol", "CredentialBrokerCore"], path: "Tests/HeadlessProtocolTests" ), .executableTarget( diff --git a/apps/headless/SecurePrompt/SecurePrompt.c b/apps/headless/SecurePrompt/SecurePrompt.c new file mode 100644 index 0000000..7d394b8 --- /dev/null +++ b/apps/headless/SecurePrompt/SecurePrompt.c @@ -0,0 +1,261 @@ +#include "CHeadlessSecurePrompt.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define HEADLESS_PROMPT_MAX_BYTES 4096 + +static volatile sig_atomic_t caught_signal = 0; +static volatile sig_atomic_t signal_pipe_write = -1; +static const int handled_signals[] = { + SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGTSTP +}; + +static void record_signal(int signal_number) { + int saved_errno = errno; + caught_signal = signal_number; + if (signal_pipe_write >= 0) { + unsigned char value = (unsigned char)signal_number; + (void)write((int)signal_pipe_write, &value, 1); + } + errno = saved_errno; +} + +static int write_all(int descriptor, const unsigned char *bytes, size_t length) { + size_t written = 0; + while (written < length) { + ssize_t result = write(descriptor, bytes + written, length - written); + if (result < 0 && errno == EINTR) { + if (caught_signal != 0) return -1; + continue; + } + if (result <= 0) { + return -1; + } + written += (size_t)result; + } + return 0; +} + +static void restore_handlers(const struct sigaction old_actions[]) { + for (size_t index = 0; index < sizeof(handled_signals) / sizeof(handled_signals[0]); index++) { + (void)sigaction(handled_signals[index], &old_actions[index], NULL); + } +} + +int headless_read_tty_line( + const char *prompt, + int hide_input, + unsigned char **output, + size_t *output_length +) { + if (output == NULL || output_length == NULL || prompt == NULL) { + return HEADLESS_PROMPT_READ_FAILED; + } + *output = NULL; + *output_length = 0; + if (!isatty(STDIN_FILENO)) { + return HEADLESS_PROMPT_NOT_TTY; + } + + int descriptor = open("/dev/tty", O_RDWR | O_NOCTTY | O_CLOEXEC); + if (descriptor < 0 || !isatty(descriptor)) { + if (descriptor >= 0) { + close(descriptor); + } + return HEADLESS_PROMPT_OPEN_FAILED; + } + if (tcgetpgrp(descriptor) != getpgrp()) { + close(descriptor); + return HEADLESS_PROMPT_NOT_FOREGROUND; + } + + struct termios original; + if (tcgetattr(descriptor, &original) != 0) { + close(descriptor); + return HEADLESS_PROMPT_TERMINAL_FAILED; + } + int signal_pipe[2] = { -1, -1 }; + if (pipe(signal_pipe) != 0 + || fcntl(signal_pipe[0], F_SETFD, FD_CLOEXEC) != 0 + || fcntl(signal_pipe[1], F_SETFD, FD_CLOEXEC) != 0 + || fcntl(signal_pipe[0], F_SETFL, O_NONBLOCK) != 0 + || fcntl(signal_pipe[1], F_SETFL, O_NONBLOCK) != 0) { + if (signal_pipe[0] >= 0) close(signal_pipe[0]); + if (signal_pipe[1] >= 0) close(signal_pipe[1]); + close(descriptor); + return HEADLESS_PROMPT_TERMINAL_FAILED; + } + + struct sigaction action; + struct sigaction old_actions[sizeof(handled_signals) / sizeof(handled_signals[0])]; + memset(&action, 0, sizeof(action)); + action.sa_handler = record_signal; + sigemptyset(&action.sa_mask); + caught_signal = 0; + signal_pipe_write = signal_pipe[1]; + for (size_t index = 0; index < sizeof(handled_signals) / sizeof(handled_signals[0]); index++) { + if (sigaction(handled_signals[index], &action, &old_actions[index]) != 0) { + while (index > 0) { + index--; + (void)sigaction(handled_signals[index], &old_actions[index], NULL); + } + signal_pipe_write = -1; + close(signal_pipe[0]); + close(signal_pipe[1]); + close(descriptor); + return HEADLESS_PROMPT_TERMINAL_FAILED; + } + } + + struct termios configured = original; + if (hide_input) { + configured.c_lflag &= (tcflag_t)~(ECHO | ECHONL | ICANON); + configured.c_cc[VMIN] = 1; + configured.c_cc[VTIME] = 0; + if (tcsetattr(descriptor, TCSAFLUSH, &configured) != 0) { + restore_handlers(old_actions); + signal_pipe_write = -1; + close(signal_pipe[0]); + close(signal_pipe[1]); + close(descriptor); + return HEADLESS_PROMPT_TERMINAL_FAILED; + } + } + + int result = HEADLESS_PROMPT_SUCCESS; + unsigned char *buffer = malloc(HEADLESS_PROMPT_MAX_BYTES + 1); + size_t length = 0; + if (caught_signal != 0) { + result = HEADLESS_PROMPT_INTERRUPTED; + goto cleanup; + } + if (buffer == NULL || write_all( + descriptor, (const unsigned char *)prompt, strlen(prompt) + ) != 0) { + result = HEADLESS_PROMPT_READ_FAILED; + goto cleanup; + } + + while (length <= HEADLESS_PROMPT_MAX_BYTES) { + if (caught_signal != 0) { + result = HEADLESS_PROMPT_INTERRUPTED; + goto cleanup; + } + fd_set readers; + FD_ZERO(&readers); + FD_SET(descriptor, &readers); + FD_SET(signal_pipe[0], &readers); + int maximum = descriptor > signal_pipe[0] ? descriptor : signal_pipe[0]; + int ready = select(maximum + 1, &readers, NULL, NULL, NULL); + if (ready < 0 && errno == EINTR) { + if (caught_signal != 0) { + result = HEADLESS_PROMPT_INTERRUPTED; + goto cleanup; + } + continue; + } + if (ready < 0) { + result = HEADLESS_PROMPT_READ_FAILED; + goto cleanup; + } + if (caught_signal != 0 || FD_ISSET(signal_pipe[0], &readers)) { + result = HEADLESS_PROMPT_INTERRUPTED; + goto cleanup; + } + if (!FD_ISSET(descriptor, &readers)) { + result = HEADLESS_PROMPT_READ_FAILED; + goto cleanup; + } + if (tcgetpgrp(descriptor) != getpgrp()) { + result = HEADLESS_PROMPT_NOT_FOREGROUND; + goto cleanup; + } + unsigned char byte = 0; + ssize_t count = read(descriptor, &byte, 1); + if (count < 0 && errno == EINTR) { + if (caught_signal != 0) { + result = HEADLESS_PROMPT_INTERRUPTED; + goto cleanup; + } + continue; + } + if (count <= 0) { + result = HEADLESS_PROMPT_READ_FAILED; + goto cleanup; + } + if (byte == '\n' || byte == '\r') { + break; + } + if (byte == 0x7f || byte == 0x08) { + if (length > 0) buffer[--length] = 0; + continue; + } + if (byte == 0x15) { + headless_secure_clear(buffer, length); + length = 0; + continue; + } + if (length == HEADLESS_PROMPT_MAX_BYTES) { + result = HEADLESS_PROMPT_TOO_LONG; + goto cleanup; + } + buffer[length++] = byte; + } + if (hide_input) { + (void)write_all(descriptor, (const unsigned char *)"\n", 1); + } + if (length == 0) { + result = HEADLESS_PROMPT_EMPTY; + goto cleanup; + } + buffer[length] = 0; + +cleanup: + if (hide_input) { + (void)tcflush(descriptor, TCIFLUSH); + if (tcsetattr(descriptor, TCSAFLUSH, &original) != 0 + && result == HEADLESS_PROMPT_SUCCESS) { + result = HEADLESS_PROMPT_TERMINAL_FAILED; + } + } + restore_handlers(old_actions); + signal_pipe_write = -1; + close(signal_pipe[0]); + close(signal_pipe[1]); + close(descriptor); + if (result == HEADLESS_PROMPT_SUCCESS) { + *output = buffer; + *output_length = length; + } else if (buffer != NULL) { + headless_clear_and_free(buffer, HEADLESS_PROMPT_MAX_BYTES + 1); + } + + if (caught_signal != 0) { + int signal_number = caught_signal; + caught_signal = 0; + raise(signal_number); + } + return result; +} + +void headless_clear_and_free(unsigned char *bytes, size_t length) { + if (bytes == NULL) { + return; + } + headless_secure_clear(bytes, length); + free(bytes); +} + +void headless_secure_clear(unsigned char *bytes, size_t length) { + volatile unsigned char *cursor = bytes; + while (length-- > 0) { + *cursor++ = 0; + } +} diff --git a/apps/headless/SecurePrompt/include/CHeadlessSecurePrompt.h b/apps/headless/SecurePrompt/include/CHeadlessSecurePrompt.h new file mode 100644 index 0000000..4ab839f --- /dev/null +++ b/apps/headless/SecurePrompt/include/CHeadlessSecurePrompt.h @@ -0,0 +1,28 @@ +#ifndef C_HEADLESS_SECURE_PROMPT_H +#define C_HEADLESS_SECURE_PROMPT_H + +#include + +enum headless_prompt_result { + HEADLESS_PROMPT_SUCCESS = 0, + HEADLESS_PROMPT_NOT_TTY = 1, + HEADLESS_PROMPT_OPEN_FAILED = 2, + HEADLESS_PROMPT_TERMINAL_FAILED = 3, + HEADLESS_PROMPT_READ_FAILED = 4, + HEADLESS_PROMPT_EMPTY = 5, + HEADLESS_PROMPT_TOO_LONG = 6, + HEADLESS_PROMPT_INTERRUPTED = 7, + HEADLESS_PROMPT_NOT_FOREGROUND = 8 +}; + +int headless_read_tty_line( + const char *prompt, + int hide_input, + unsigned char **output, + size_t *output_length +); + +void headless_clear_and_free(unsigned char *bytes, size_t length); +void headless_secure_clear(unsigned char *bytes, size_t length); + +#endif diff --git a/apps/headless/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index f2d45b9..c70cba8 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -1,5 +1,10 @@ import HeadlessProtocol import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif private func printJSON(_ value: JSONValue) { do { @@ -156,6 +161,75 @@ private enum HostLaunchError: Error, CustomStringConvertible { } } +private struct CredentialBrokerLauncher { + func run(_ command: CredentialCLICommand) throws { + let executable = try resolveExecutable() + let arguments = [executable.path, "credentials"] + command.brokerArguments + ["--json"] + let environment = sanitizedEnvironment(ProcessInfo.processInfo.environment) + .map { "\($0.key)=\($0.value)" }.sorted() + var argumentPointers = arguments.map { value in value.withCString(strdup) } + [nil] + var environmentPointers = environment.map { value in value.withCString(strdup) } + [nil] + defer { + for case let pointer? in argumentPointers { free(UnsafeMutableRawPointer(pointer)) } + for case let pointer? in environmentPointers { free(UnsafeMutableRawPointer(pointer)) } + } + #if canImport(Darwin) + Darwin.execve(executable.path, &argumentPointers, &environmentPointers) + #else + Glibc.execve(executable.path, &argumentPointers, &environmentPointers) + #endif + throw CredentialBrokerLaunchError.unavailable + } + + private func resolveExecutable() throws -> URL { + let cli = try runningExecutableURL() + let candidate = cli.deletingLastPathComponent().appendingPathComponent("headless-credential-broker") + var info = stat() + guard lstat(candidate.path, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + (info.st_uid == getuid() || info.st_uid == 0), (info.st_mode & 0o022) == 0, + FileManager.default.isExecutableFile(atPath: candidate.path) else { + throw CredentialBrokerLaunchError.unavailable + } + return candidate + } + + private func runningExecutableURL() throws -> URL { + #if os(macOS) + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + var buffer = [CChar](repeating: 0, count: Int(size)) + let status = buffer.withUnsafeMutableBufferPointer { + _NSGetExecutablePath($0.baseAddress, &size) + } + guard status == 0 else { throw CredentialBrokerLaunchError.unavailable } + return URL(fileURLWithPath: String(cString: buffer)).resolvingSymlinksInPath() + #else + guard let path = try? FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") else { + throw CredentialBrokerLaunchError.unavailable + } + return URL(fileURLWithPath: path).standardizedFileURL + #endif + } + + private func sanitizedEnvironment(_ source: [String: String]) -> [String: String] { + let allowed = [ + "HOME", "USER", "LOGNAME", "DISPLAY", "WAYLAND_DISPLAY", "LANG", "TERM", "COLORTERM", + "__CF_USER_TEXT_ENCODING", + ] + var result = source.filter { allowed.contains($0.key) || $0.key.hasPrefix("LC_") } + result["PATH"] = "/usr/bin:/bin" + return result + } +} + +private enum CredentialBrokerLaunchError: Error, CustomStringConvertible { + case unavailable + + var description: String { + "The trusted headless-credential-broker executable is missing or insecure. Reinstall Headless." + } +} + do { let invocation = try CLIParser().parse(Array(CommandLine.arguments.dropFirst())) if let local = invocation.local { @@ -192,6 +266,8 @@ do { "startupPresentation": .string(presentation.rawValue), "takesEffect": .string("next-host-start"), ])) + case .credentials(let command): + try CredentialBrokerLauncher().run(command) } } else if let request = invocation.request { let launcher = HostLauncher() @@ -208,6 +284,9 @@ do { } catch let error as CLIParseError { fputs("headless: \(error.description)\n", stderr) exit(64) +} catch let error as CredentialCommandError { + fputs("headless: \(error.description)\n", stderr) + exit(64) } catch let error as ProtocolValidationError { fputs("headless: \(error.description)\n", stderr) exit(64) @@ -242,6 +321,12 @@ do { ) try? printResponse(response) exit(69) +} catch let error as CredentialBrokerLaunchError { + let response = CommandResponse.failure( + id: "unknown", code: "VAULT_UNAVAILABLE", message: error.description + ) + try? printResponse(response) + exit(69) } catch { fputs("headless: \(error)\n", stderr) exit(70) diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 1ddad2d..b9cd269 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -13,6 +13,7 @@ public enum LocalCommand: Equatable, Sendable { case start(presentation: AgentStartupPresentation?) case getStartupPresentation case setStartupPresentation(AgentStartupPresentation) + case credentials(CredentialCLICommand) } public struct CLIInvocation: Equatable, Sendable { @@ -116,6 +117,9 @@ public struct CLIParser { default: throw CLIParseError.invalidOption(arguments.first ?? "config") } + case "credentials": + guard session == nil else { throw CLIParseError.invalidOption("--session") } + return try parseCredentials(arguments) case "status": try requireEmpty(arguments) return remote(.ping, session: session, jsonOutput: jsonOutput) @@ -226,6 +230,58 @@ public struct CLIParser { } } + private func parseCredentials(_ arguments: [String]) throws -> CLIInvocation { + guard let subcommand = arguments.first else { + throw CLIParseError.missingArgument("credentials list|add|rename|remove") + } + var args = Array(arguments.dropFirst()) + let originValue = try removeOption("--origin", from: &args) + let aliasValue = try removeOption("--alias", from: &args) + switch subcommand { + case "list": + try requireNoCredentialArguments(args) + let origin = try originValue.map(CredentialOrigin.init(rawValue:)) + guard aliasValue == nil else { throw CLIParseError.invalidOption("--alias") } + return CLIInvocation(local: .credentials(.list(origin: origin)), jsonOutput: true) + case "add": + guard removeFlag("--interactive", from: &args) else { + throw CLIParseError.missingArgument("--interactive") + } + try requireNoCredentialArguments(args) + guard let originValue else { throw CLIParseError.missingArgument("--origin") } + guard let aliasValue else { throw CLIParseError.missingArgument("--alias") } + return CLIInvocation(local: .credentials(.add( + origin: try CredentialOrigin(rawValue: originValue), + alias: try CredentialAlias(rawValue: aliasValue) + )), jsonOutput: true) + case "rename": + let newAliasValue = try removeOption("--to", from: &args) + try requireNoCredentialArguments(args) + guard let originValue else { throw CLIParseError.missingArgument("--origin") } + guard let aliasValue else { throw CLIParseError.missingArgument("--alias") } + guard let newAliasValue else { throw CLIParseError.missingArgument("--to") } + return CLIInvocation(local: .credentials(.rename( + origin: try CredentialOrigin(rawValue: originValue), + alias: try CredentialAlias(rawValue: aliasValue), + newAlias: try CredentialAlias(rawValue: newAliasValue) + )), jsonOutput: true) + case "remove": + try requireNoCredentialArguments(args) + guard let originValue else { throw CLIParseError.missingArgument("--origin") } + guard let aliasValue else { throw CLIParseError.missingArgument("--alias") } + return CLIInvocation(local: .credentials(.remove( + origin: try CredentialOrigin(rawValue: originValue), + alias: try CredentialAlias(rawValue: aliasValue) + )), jsonOutput: true) + default: + throw CredentialCommandError.invalidArguments + } + } + + private func requireNoCredentialArguments(_ arguments: [String]) throws { + guard arguments.isEmpty else { throw CredentialCommandError.invalidArguments } + } + private func parseInspect(_ arguments: [String], session: String?, jsonOutput: Bool) throws -> CLIInvocation { var args = arguments let interactive = removeFlag("--interactive", from: &args) @@ -679,6 +735,10 @@ Commands: profile clear config get startup-presentation config set startup-presentation background|foreground + credentials list [--origin URL] + credentials add --origin URL --alias NAME --interactive + credentials rename --origin URL --alias OLD --to NEW + credentials remove --origin URL --alias NAME session create [NAME] | session list | session close NAME visit URL inspect [--context summary|outline|text|actions|full] [--task TEXT] diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index 61be634..c136948 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -150,6 +150,13 @@ public let capabilitiesDocument: JSONValue = { }) let screenshotExtensions = ScreenshotFormat.artifactExtensions.sorted() let recordingExtensions = RecordingFormat.artifactExtensions.sorted() + #if os(macOS) + let credentialBackend = "macos-login-keychain" + let credentialSecurityTier = "local-unnotarized" + #else + let credentialBackend = "linux-secret-service" + let credentialSecurityTier = "os-secure-store" + #endif return .object([ "protocolVersion": .string(headlessProtocolVersion), "transport": stringArray(["local-unix-socket"]), @@ -171,6 +178,20 @@ public let capabilitiesDocument: JSONValue = { "maximumOutlineDepth": .number(8), ]), "screenshotSeries": stringArray(["viewport", "section"]), + "localCommands": stringArray([ + "credentials.add", "credentials.list", "credentials.remove", "credentials.rename", + ]), + "credentialVault": .object([ + "supported": .bool(true), + "backend": .string(credentialBackend), + "securityTier": .string(credentialSecurityTier), + "availability": .string("checked-at-command-time"), + "passwordTransport": .string("dedicated-local-broker"), + "userPresence": .string("required-on-every-use-by-broker"), + "silentUse": .bool(false), + "agentReceivesPasswords": .bool(false), + "privateContextAccess": .bool(false), + ]), "security": .object([ "tcpListener": .bool(false), "arbitraryJavaScript": .bool(false), diff --git a/apps/headless/Sources/HeadlessProtocol/CredentialCommands.swift b/apps/headless/Sources/HeadlessProtocol/CredentialCommands.swift new file mode 100644 index 0000000..1e0b20c --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/CredentialCommands.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct CredentialOrigin: Codable, Equatable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) throws { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.unicodeScalars.allSatisfy(\.isASCII), + let url = try? normalizedWebURL(trimmed) else { + throw CredentialCommandError.invalidOrigin + } + guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased(), + !host.contains("%"), host.unicodeScalars.allSatisfy(\.isASCII), + url.path.isEmpty || url.path == "/", + url.query == nil, url.fragment == nil else { + throw CredentialCommandError.invalidOrigin + } + guard scheme == "https" || (scheme == "http" && isCredentialDevelopmentHost(host)) else { + throw CredentialCommandError.invalidOrigin + } + guard url.user == nil, url.password == nil else { + throw CredentialCommandError.invalidOrigin + } + + var canonical = "\(scheme)://" + canonical += host.contains(":") ? "[\(host)]" : host + if let port = url.port, !((scheme == "https" && port == 443) || (scheme == "http" && port == 80)) { + canonical += ":\(port)" + } + self.rawValue = canonical + } + + public init(from decoder: Decoder) throws { + try self.init(rawValue: decoder.singleValueContainer().decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +private func isCredentialDevelopmentHost(_ host: String) -> Bool { + ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) +} + +public struct CredentialAlias: Codable, Equatable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) throws { + let bytes = Array(rawValue.utf8) + guard !bytes.isEmpty, bytes.count <= 64, + bytes.allSatisfy({ byte in + (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) || [45, 46, 95].contains(byte) + }), bytes[0] != 45, bytes[0] != 46, bytes[0] != 95 else { + throw CredentialCommandError.invalidAlias + } + self.rawValue = rawValue + } + + public init(from decoder: Decoder) throws { + try self.init(rawValue: decoder.singleValueContainer().decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public enum CredentialCommandError: Error, Equatable, CustomStringConvertible { + case invalidOrigin + case invalidAlias + case invalidArguments + + public var description: String { + switch self { + case .invalidOrigin: + return "Credential origins must be exact HTTPS origins; HTTP is limited to localhost loopback development." + case .invalidAlias: + return "Credential aliases must be 1-64 letters, numbers, periods, underscores, or hyphens." + case .invalidArguments: + return "Invalid credential command arguments. Password values are accepted only by the interactive prompt." + } + } +} + +public enum CredentialCLICommand: Equatable, Sendable { + case list(origin: CredentialOrigin?) + case add(origin: CredentialOrigin, alias: CredentialAlias) + case rename(origin: CredentialOrigin, alias: CredentialAlias, newAlias: CredentialAlias) + case remove(origin: CredentialOrigin, alias: CredentialAlias) + + public var brokerArguments: [String] { + switch self { + case .list(let origin): + return ["list"] + (origin.map { ["--origin", $0.rawValue] } ?? []) + case .add(let origin, let alias): + return ["add", "--origin", origin.rawValue, "--alias", alias.rawValue, "--interactive"] + case .rename(let origin, let alias, let newAlias): + return [ + "rename", "--origin", origin.rawValue, "--alias", alias.rawValue, + "--to", newAlias.rawValue, + ] + case .remove(let origin, let alias): + return ["remove", "--origin", origin.rawValue, "--alias", alias.rawValue] + } + } +} diff --git a/apps/headless/Tests/HeadlessMCPTests/main.swift b/apps/headless/Tests/HeadlessMCPTests/main.swift index 41b6a15..9eb358e 100644 --- a/apps/headless/Tests/HeadlessMCPTests/main.swift +++ b/apps/headless/Tests/HeadlessMCPTests/main.swift @@ -62,7 +62,10 @@ func run() throws { #"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["session","close","disposable"]}}}"#, "not-json", String(repeating: "x", count: headlessMaximumMessageBytes + 1), - #"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["start"]}}}"#, + #"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["fill","@e1","credentials"]}}}"#, + #"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["credentials","add","synthetic-password"]}}}"#, + #"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["credentials","list"]}}}"#, + #"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["start"]}}}"#, ] try process.run() @@ -78,7 +81,7 @@ func run() throws { let value = try JSONSerialization.jsonObject(with: Data(line.utf8)) return try object(value, "MCP response was not a JSON object") } - try expect(responses.count == 8, "expected eight MCP responses, received \(responses.count)") + try expect(responses.count == 11, "expected eleven MCP responses, received \(responses.count)") let initialize = try object(responses[0]["result"], "initialize result was absent") try expect(initialize["protocolVersion"] as? String == "2025-06-18", "initialize protocol version changed") @@ -140,7 +143,33 @@ func run() throws { try expect(code == -32700, "invalid input returned the wrong error code") } - let localCall = try object(responses[7]["result"], "local-command result was absent") + let fillCall = try object(responses[7]["result"], "fill result was absent") + try expect(fillCall["isError"] as? Bool == false, "credential-like fill value was rejected") + + let malformedCredentialCall = try object( + responses[8]["result"], "malformed credential rejection result was absent" + ) + guard let malformedCredentialContent = malformedCredentialCall["content"] as? [[String: Any]], + let malformedCredentialText = malformedCredentialContent.first?["text"] as? String else { + throw TestFailure(description: "malformed credential rejection text was absent") + } + try expect( + !malformedCredentialText.contains("synthetic-password"), + "MCP credential parse errors must redact rejected values" + ) + + let credentialCall = try object(responses[9]["result"], "credential rejection result was absent") + try expect(credentialCall["isError"] as? Bool == true, "credential command was accepted over MCP") + guard let credentialContent = credentialCall["content"] as? [[String: Any]], + let credentialText = credentialContent.first?["text"] as? String else { + throw TestFailure(description: "credential rejection text was absent") + } + try expect( + credentialText.contains("direct local user interaction"), + "credential rejection should direct the caller to a local terminal" + ) + + let localCall = try object(responses[10]["result"], "local-command result was absent") try expect(localCall["isError"] as? Bool == true, "local CLI command was accepted over MCP") guard let localContent = localCall["content"] as? [[String: Any]], let localText = localContent.first?["text"] as? String else { diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index c91a948..537ad89 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1,4 +1,5 @@ import HeadlessProtocol +import CredentialBrokerCore import Foundation #if canImport(Darwin) import Darwin @@ -187,6 +188,48 @@ private final class TestBrowserSession: BrowserEngineSession { func hostAnimations() throws -> JSONValue { .object(["animations": .array([])]) } } +private final class TestCredentialPrompt: CredentialPrompting { + let account: String + private var passwords: [[UInt8]] + + init(account: String, passwords: [String]) { + self.account = account + self.passwords = passwords.map { Array($0.utf8) } + } + + func readAccount() throws -> String { account } + + func readPassword() throws -> SensitiveBytes { + guard !passwords.isEmpty else { throw CredentialVaultError.promptFailed } + return SensitiveBytes(passwords.removeFirst()) + } + + func readPasswordConfirmation() throws -> SensitiveBytes { + try readPassword() + } +} + +private final class TestCredentialSecretStore: CredentialSecretStore { + let backendName = "fake-secure-vault" + var records: [String: CredentialRecord] = [:] + var storedSecretBytes: [UInt8] = [] + var storeError: CredentialVaultError? + var removeError: CredentialVaultError? + var afterStore: ((CredentialRecord) -> Void)? + + func store(_ secret: SensitiveBytes, for record: CredentialRecord) throws { + if let storeError { throw storeError } + storedSecretBytes = secret.withUnsafeBytes { Array($0) } + records[record.id] = record + afterStore?(record) + } + + func remove(recordID: String) throws { + if let removeError { throw removeError } + records.removeValue(forKey: recordID) + } +} + private final class TestBrowserEngine: BrowserEngine { typealias Session = TestBrowserSession @@ -800,6 +843,10 @@ struct ProtocolTests { (["config", "get", "startup-presentation"], .getStartupPresentation), (["config", "set", "startup-presentation", "background"], .setStartupPresentation(.background)), (["config", "set", "startup-presentation", "foreground"], .setStartupPresentation(.foreground)), + (["credentials", "list"], .credentials(.list(origin: nil))), + (["credentials", "list", "--origin", "https://example.com"], .credentials(.list( + origin: try CredentialOrigin(rawValue: "https://example.com") + ))), (["help"], .help), (["--help"], .help), (["version"], .version), @@ -822,6 +869,9 @@ struct ProtocolTests { try expectThrows("startup presentation should reject unknown values") { _ = try CLIParser().parse(["config", "set", "startup-presentation", "automatic"]) } + try expectThrows("credential commands must reject browser sessions") { + _ = try CLIParser().parse(["--session", "qa", "credentials", "list"]) + } let sessionCreate = try CLIParser().parse(["session", "create", "qa"]) try expect(sessionCreate.request?.parameters["name"] == .string("qa"), "session create name should parse") @@ -840,6 +890,189 @@ struct ProtocolTests { } } + static func credentialCommandSecurity() throws { + try expect( + try CredentialOrigin(rawValue: "HTTPS://EXAMPLE.COM:443/").rawValue == "https://example.com", + "credential origins should be canonical" + ) + try expect( + try CredentialOrigin(rawValue: "localhost:4173").rawValue == "http://localhost:4173", + "localhost credentials should use the documented development exception" + ) + for unsafe in [ + "http://example.com", "http://0.0.0.0:4173", "https://user:pass@example.com", + "https://example.com/login", "https://example.com?next=login", "https://example.com/#login", + "https://éxample.com", + ] { + try expectThrows("unsafe credential origin should fail without echoing input") { + _ = try CredentialOrigin(rawValue: unsafe) + } + } + for unsafe in [".hidden", "work account", "wørk", "alias/../../secret", String(repeating: "a", count: 65)] { + try expectThrows("unsafe credential alias should fail") { + _ = try CredentialAlias(rawValue: unsafe) + } + } + + let invocation = try CLIParser().parse([ + "credentials", "add", "--origin", "https://example.com", "--alias", "work", "--interactive", + ]) + guard case .credentials(let command)? = invocation.local else { + throw TestFailure(description: "credential add should remain a local command") + } + let brokerArguments = command.brokerArguments.joined(separator: " ") + try expect(!brokerArguments.lowercased().contains("password"), "broker argv must not carry passwords") + try expectThrows("credential add must require interactive input") { + _ = try CLIParser().parse([ + "credentials", "add", "--origin", "https://example.com", "--alias", "work", + ]) + } + do { + _ = try CLIParser().parse([ + "credentials", "add", "--origin", "https://example.com", "--alias", "work", + "--interactive", "synthetic-secret-that-must-not-echo", + ]) + throw TestFailure(description: "credential positional secret should be rejected") + } catch let error as CredentialCommandError { + try expect( + !error.description.contains("synthetic-secret-that-must-not-echo"), + "credential parse errors must redact rejected values" + ) + } + } + + static func credentialVaultLifecycle() throws { + let root = URL(fileURLWithPath: "/tmp/headless-credential-test-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let origin = try CredentialOrigin(rawValue: "https://example.com") + let work = try CredentialAlias(rawValue: "work") + let store = TestCredentialSecretStore() + let metadata = CredentialMetadataStore(rootURL: root) + let controller = CredentialVaultController( + metadata: metadata, secrets: store, + prompt: TestCredentialPrompt(account: "person@example.com", passwords: ["synthetic-secret", "synthetic-secret"]) + ) + + let added = try controller.add(origin: origin, alias: work) + let encoded = String(decoding: try ProtocolCodec.encoder.encode(added), as: UTF8.self) + try expect(!encoded.contains("synthetic-secret"), "vault output must not contain password bytes") + try expect(store.storedSecretBytes == Array("synthetic-secret".utf8), "fake vault should receive exact secret bytes") + let listing = String(decoding: try ProtocolCodec.encoder.encode(controller.list(origin: origin)), as: UTF8.self) + try expect(listing.contains("person@example.com"), "listing should expose approved username metadata") + try expect(!listing.contains("synthetic-secret"), "listing must never contain passwords") + + let duplicate = CredentialVaultController( + metadata: metadata, secrets: store, + prompt: TestCredentialPrompt(account: "other@example.com", passwords: ["different", "different"]) + ) + do { + _ = try duplicate.add(origin: origin, alias: try CredentialAlias(rawValue: "WORK")) + throw TestFailure(description: "case-insensitive duplicate alias should fail") + } catch CredentialVaultError.duplicateAlias {} + + let personal = try CredentialAlias(rawValue: "personal") + _ = try controller.rename(origin: origin, alias: work, to: personal) + let renamedListing = String( + decoding: try ProtocolCodec.encoder.encode(controller.list(origin: origin)), as: UTF8.self + ) + try expect(renamedListing.contains("personal"), "rename should update private index metadata") + store.removeError = .vaultLocked + do { + _ = try controller.remove(origin: origin, alias: personal) + throw TestFailure(description: "locked secure-store removal should fail") + } catch CredentialVaultError.vaultLocked {} + let afterRollback = String(decoding: try ProtocolCodec.encoder.encode(controller.list(origin: origin)), as: UTF8.self) + try expect(afterRollback.contains("personal"), "failed removal should restore index metadata") + store.removeError = nil + _ = try controller.remove(origin: origin, alias: personal) + let empty = String(decoding: try ProtocolCodec.encoder.encode(controller.list(origin: origin)), as: UTF8.self) + try expect(empty.contains("\"total\":0"), "removed credential should leave no active metadata") + + let index = root.appendingPathComponent("credentials-index.json") + try Data("not-json".utf8).write(to: index) + _ = chmod(index.path, 0o600) + try expectThrows("corrupt credential metadata should fail closed") { + _ = try controller.list(origin: nil) + } + } + + static func credentialVaultRejectsMismatchedConfirmation() throws { + let root = URL(fileURLWithPath: "/tmp/headless-credential-mismatch-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let store = TestCredentialSecretStore() + let controller = CredentialVaultController( + metadata: CredentialMetadataStore(rootURL: root), secrets: store, + prompt: TestCredentialPrompt(account: "person@example.com", passwords: ["first", "second"]) + ) + try expectThrows("mismatched password confirmation should fail") { + _ = try controller.add( + origin: try CredentialOrigin(rawValue: "https://example.com"), + alias: try CredentialAlias(rawValue: "work") + ) + } + try expect(store.records.isEmpty, "mismatched confirmation must not reach the secure store") + } + + static func credentialVaultRollsBackFailedMetadataCommit() throws { + let root = URL(fileURLWithPath: "/tmp/headless-credential-rollback-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let store = TestCredentialSecretStore() + store.afterStore = { _ in + try? FileManager.default.removeItem( + at: root.appendingPathComponent("credentials-index.json") + ) + try? FileManager.default.createDirectory( + at: root.appendingPathComponent("credentials-index.json"), + withIntermediateDirectories: false + ) + } + let controller = CredentialVaultController( + metadata: CredentialMetadataStore(rootURL: root), secrets: store, + prompt: TestCredentialPrompt(account: "person@example.com", passwords: ["first", "first"]) + ) + try expectThrows("failed metadata commit should reject credential enrollment") { + _ = try controller.add( + origin: try CredentialOrigin(rawValue: "https://example.com"), + alias: try CredentialAlias(rawValue: "work") + ) + } + try expect(store.records.isEmpty, "failed metadata commit must remove the new secure-store item") + } + + static func credentialVaultRecoversInterruptedTransactions() throws { + for kind in [CredentialTransactionKind.add, .remove] { + let root = URL( + fileURLWithPath: "/tmp/headless-credential-recovery-\(kind.rawValue)-\(UUID().uuidString)" + ) + defer { try? FileManager.default.removeItem(at: root) } + let record = try CredentialRecord( + origin: CredentialOrigin(rawValue: "https://example.com"), + alias: CredentialAlias(rawValue: "work"), + account: "person@example.com" + ) + let metadata = CredentialMetadataStore(rootURL: root) + try metadata.withLockedState { transaction in + transaction.state.pending = [CredentialPendingTransaction(kind: kind, record: record)] + try transaction.save() + } + let store = TestCredentialSecretStore() + store.records[record.id] = record + let controller = CredentialVaultController( + metadata: metadata, secrets: store, + prompt: TestCredentialPrompt(account: "unused", passwords: []) + ) + + _ = try controller.list(origin: nil) + try expect(store.records.isEmpty, "interrupted \(kind.rawValue) should remove the vault item") + try metadata.withLockedState { transaction in + try expect( + transaction.state.pending.isEmpty, + "interrupted \(kind.rawValue) journal should be cleared" + ) + } + } + } + static func chromiumRuntimeSelection() throws { let runtimeInvocation = try CLIParser().parse(["runtime"]) try expect(runtimeInvocation.local == .runtime, "runtime diagnostics command should parse") @@ -2047,6 +2280,11 @@ struct ProtocolTests { ("CLI P1 artifacts", cliP1Artifacts), ("CLI P2 commands and boundaries", cliP2CommandsAndBoundaries), ("CLI command matrix", cliCommandMatrix), + ("credential command security", credentialCommandSecurity), + ("credential vault lifecycle", credentialVaultLifecycle), + ("credential confirmation", credentialVaultRejectsMismatchedConfirmation), + ("credential metadata rollback", credentialVaultRollsBackFailedMetadataCommit), + ("credential transaction recovery", credentialVaultRecoversInterruptedTransactions), ("Chromium runtime selection", chromiumRuntimeSelection), ("artifact store round-trip", artifactStoreRoundTrip), ("artifact read boundaries", artifactReadsStayInsideBounds), diff --git a/apps/headless/Tests/linux-credential-vault.sh b/apps/headless/Tests/linux-credential-vault.sh new file mode 100644 index 0000000..241674d --- /dev/null +++ b/apps/headless/Tests/linux-credential-vault.sh @@ -0,0 +1,80 @@ +#!/bin/sh +set -eu + +RUNTIME_DIR="/run/user/$(id -u)" +BUS_ADDRESS="unix:path=$RUNTIME_DIR/bus" +BUS_PID="" +ORIGIN="https://credentials.example.test" +ALIAS="ci-work" +RENAMED_ALIAS="ci-renamed" +ACCOUNT="vault-user@example.test" +PASSWORD="synthetic-vault-password" + +cleanup() { + gnome-keyring-daemon --shutdown >/dev/null 2>&1 || true + if [ -n "$BUS_PID" ]; then kill "$BUS_PID" >/dev/null 2>&1 || true; fi + rm -rf "$HOME/.local/share/headless/credential-vault" + rm -rf "$HOME/.local/share/keyrings" + rm -f "$RUNTIME_DIR/bus" +} +trap cleanup EXIT INT TERM + +UNTRUSTED_BUS="unix:path=/tmp/headless-untrusted-session-bus" +if RESULT="$(DBUS_SESSION_BUS_ADDRESS="$UNTRUSTED_BUS" headless credentials list 2>&1)"; then + echo "caller-selected D-Bus address was accepted without the canonical user bus" >&2 + exit 1 +fi +echo "$RESULT" | grep -q 'VAULT_UNAVAILABLE' + +dbus-daemon --session --address="$BUS_ADDRESS" --fork --print-pid=1 > "$RUNTIME_DIR/dbus.pid" +BUS_PID="$(cat "$RUNTIME_DIR/dbus.pid")" +export XDG_RUNTIME_DIR="$RUNTIME_DIR" +export DBUS_SESSION_BUS_ADDRESS="$BUS_ADDRESS" +KEYRING_ENV="$(printf '%s' 'synthetic-keyring-password' | \ + gnome-keyring-daemon --unlock --components=secrets)" +eval "$KEYRING_ENV" + +expect <&2 + exit 1 +fi +secret-tool search application com.headless.credentials.v1 >/dev/null + +headless credentials rename --origin "$ORIGIN" --alias "$ALIAS" \ + --to "$RENAMED_ALIAS" | grep -q '"renamed":true' +headless credentials list --origin "$ORIGIN" | grep -q "$RENAMED_ALIAS" +headless credentials remove --origin "$ORIGIN" --alias "$RENAMED_ALIAS" \ + | grep -q '"removed":true' +if [ -n "$(secret-tool search application com.headless.credentials.v1 2>/dev/null)" ]; then + echo "removed credential remained in Secret Service" >&2 + exit 1 +fi + +INDEX="$HOME/.local/share/headless/credential-vault/credentials-index.json" +test "$(stat -c %a "$INDEX")" = "600" +test "$(stat -c %a "$(dirname "$INDEX")")" = "700" +if grep -q "$PASSWORD" "$INDEX"; then + echo "credential index contained a password" >&2 + exit 1 +fi + +echo "Linux Secret Service credential lifecycle passed" diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index e957cc0..0d7817d 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -30,6 +30,7 @@ trap cleanup EXIT INT TERM /opt/headless/package/install-linux.sh --prefix "$INSTALL_ROOT" | grep -q 'Headless installed' test -x "$INSTALL_ROOT/bin/headless" test -x "$INSTALL_ROOT/bin/headless-host" +test -x "$INSTALL_ROOT/bin/headless-credential-broker" test -r "$INSTALL_ROOT/bin/Headless_HeadlessProtocol.resources/AgentRuntime.js" if /opt/headless/package/install-linux.sh --prefix relative/path >/dev/null 2>&1; then echo "relative install prefix was not rejected" >&2 @@ -43,6 +44,14 @@ fi echo "$SNAP_INSTALL" | grep -q 'UNSUPPORTED_BROWSER_RUNTIME' echo "$SNAP_INSTALL" | grep -q 'Snap Chromium is not reliable' +if CREDENTIAL_LIST="$(headless credentials list 2>&1)"; then + echo "credential vault did not fail closed without a Secret Service session" >&2 + exit 1 +fi +echo "$CREDENTIAL_LIST" | grep -q 'VAULT_UNAVAILABLE' +test ! -e "$HOME/.local/share/headless/credential-vault/credentials-index.json" +/opt/headless/linux-credential-vault.sh + headless runtime | grep -q '"executable":"/usr/lib/chromium/chromium"' headless runtime | grep -q '"transport":"inherited-devtools-pipe"' if SNAP_RUNTIME="$(HEADLESS_CHROMIUM_EXECUTABLE=/snap/bin/chromium headless runtime 2>&1)"; then diff --git a/apps/headless/Tests/linux-installer.sh b/apps/headless/Tests/linux-installer.sh index f05b43e..e46b816 100755 --- a/apps/headless/Tests/linux-installer.sh +++ b/apps/headless/Tests/linux-installer.sh @@ -45,17 +45,19 @@ exit 64 EOF printf '#!/bin/sh\nexit 0\n' > "$BUNDLE/headless-host" printf '#!/bin/sh\nexit 0\n' > "$BUNDLE/headless-mcp" +printf '#!/bin/sh\nexit 0\n' > "$BUNDLE/headless-credential-broker" printf '#!/bin/sh\nexit 0\n' > "$ROOT/ffmpeg" printf 'fixture runtime\n' > "$BUNDLE/Headless_HeadlessProtocol.resources/AgentRuntime.js" printf 'P1\n' > "$BUNDLE/P1.md" printf 'P2\n' > "$BUNDLE/P2.md" cp install-linux.sh "$BUNDLE/install-linux.sh" chmod 0755 "$BUNDLE/headless" "$BUNDLE/headless-host" "$BUNDLE/headless-mcp" \ + "$BUNDLE/headless-credential-broker" \ "$BUNDLE/install-linux.sh" "$ROOT/ffmpeg" ASSET="headless-9.8.7-linux-amd64.tar.gz" tar -czf "$RELEASE/$ASSET" -C "$BUNDLE" \ - headless headless-host headless-mcp Headless_HeadlessProtocol.resources install-linux.sh P1.md P2.md + headless headless-host headless-mcp headless-credential-broker Headless_HeadlessProtocol.resources install-linux.sh P1.md P2.md if command -v sha256sum >/dev/null 2>&1; then (cd "$RELEASE" && sha256sum "$ASSET") > "$RELEASE/SHA256SUMS" else @@ -73,6 +75,7 @@ grep -q "Headless installed" <<< "$EXPLICIT_OUTPUT" test -x "$PREFIX_EXPLICIT/bin/headless" test -x "$PREFIX_EXPLICIT/bin/headless-host" test -x "$PREFIX_EXPLICIT/bin/headless-mcp" +test -x "$PREFIX_EXPLICIT/bin/headless-credential-broker" test -r "$PREFIX_EXPLICIT/bin/Headless_HeadlessProtocol.resources/AgentRuntime.js" PREFIX_LATEST="$ROOT/latest" @@ -91,7 +94,7 @@ mv "$RELEASE/original.tar.gz" "$RELEASE/$ASSET" cp "$RELEASE/$ASSET" "$RELEASE/safe.tar.gz" printf 'unexpected\n' > "$BUNDLE/unexpected" tar -czf "$RELEASE/$ASSET" -C "$BUNDLE" \ - headless headless-host headless-mcp Headless_HeadlessProtocol.resources install-linux.sh P1.md P2.md unexpected + headless headless-host headless-mcp headless-credential-broker Headless_HeadlessProtocol.resources install-linux.sh P1.md P2.md unexpected if command -v sha256sum >/dev/null 2>&1; then (cd "$RELEASE" && sha256sum "$ASSET") > "$RELEASE/SHA256SUMS" else diff --git a/apps/headless/Tests/macos-distribution.sh b/apps/headless/Tests/macos-distribution.sh index 2c5a66f..4699747 100755 --- a/apps/headless/Tests/macos-distribution.sh +++ b/apps/headless/Tests/macos-distribution.sh @@ -30,6 +30,7 @@ EXECUTABLES=" $APP/Contents/MacOS/Headless $APP/Contents/Resources/bin/headless $APP/Contents/Resources/bin/headless-mcp +$APP/Contents/Resources/bin/headless-credential-broker " printf '%s\n' "$EXECUTABLES" | while IFS= read -r executable; do [ -n "$executable" ] || continue diff --git a/apps/headless/Tests/secure-prompt.c b/apps/headless/Tests/secure-prompt.c new file mode 100644 index 0000000..8aa6534 --- /dev/null +++ b/apps/headless/Tests/secure-prompt.c @@ -0,0 +1,237 @@ +#if defined(__APPLE__) +#include +#else +#include +#endif + +#include "CHeadlessSecurePrompt.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *current_test = "startup"; +static volatile sig_atomic_t observed_signal = 0; + +static void observe_signal(int signal_number) { + observed_signal = signal_number; +} + +static void fail(const char *message) { + fprintf(stderr, "secure prompt test (%s): %s\n", current_test, message); + exit(1); +} + +static void wait_for_prompt(int descriptor) { + const char *expected = "Password: "; + size_t matched = 0; + while (matched < strlen(expected)) { + fd_set readers; + FD_ZERO(&readers); + FD_SET(descriptor, &readers); + struct timeval timeout = { .tv_sec = 3, .tv_usec = 0 }; + int ready = select(descriptor + 1, &readers, NULL, NULL, &timeout); + if (ready <= 0) { + fail("timed out waiting for password prompt"); + } + unsigned char byte = 0; + if (read(descriptor, &byte, 1) != 1) { + fail("could not read password prompt"); + } + if (byte == (unsigned char)expected[matched]) { + matched++; + } else { + matched = byte == (unsigned char)expected[0] ? 1 : 0; + } + } +} + +static pid_t start_prompt(int *master, int expected_result, int expected_signal) { + pid_t child = forkpty(master, NULL, NULL, NULL); + if (child < 0) { + fail("forkpty failed"); + } + if (child == 0) { + if (expected_signal != 0) { + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = observe_signal; + sigemptyset(&action.sa_mask); + if (sigaction(expected_signal, &action, NULL) != 0) { + _exit(5); + } + } + unsigned char *secret = NULL; + size_t length = 0; + int result = headless_read_tty_line("Password: ", 1, &secret, &length); + if (result != expected_result) { + _exit(20 + result); + } + if (result == HEADLESS_PROMPT_SUCCESS) { + const char expected[] = "synthetic-password"; + if (length != strlen(expected) || memcmp(secret, expected, length) != 0) { + _exit(3); + } + headless_clear_and_free(secret, length + 1); + } + struct termios restored; + if (tcgetattr(STDIN_FILENO, &restored) != 0 || (restored.c_lflag & ECHO) == 0) { + _exit(4); + } + if (expected_signal != 0 && observed_signal != expected_signal) { + _exit(6); + } + _exit(0); + } + wait_for_prompt(*master); + return child; +} + +static int secret_was_echoed(int descriptor, const char *secret) { + unsigned char output[512]; + size_t length = 0; + fd_set readers; + FD_ZERO(&readers); + FD_SET(descriptor, &readers); + struct timeval timeout = { .tv_sec = 3, .tv_usec = 0 }; + if (select(descriptor + 1, &readers, NULL, NULL, &timeout) <= 0) { + fail("timed out waiting for prompt completion output"); + } + usleep(50000); + int flags = fcntl(descriptor, F_GETFL); + if (flags < 0 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) != 0) { + fail("could not make pseudo-terminal output nonblocking"); + } + while (length < sizeof(output)) { + ssize_t count = read(descriptor, output + length, sizeof(output) - length); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) break; + length += (size_t)count; + } + if (length >= strlen(secret)) { + for (size_t index = 0; index <= length - strlen(secret); index++) { + if (memcmp(output + index, secret, strlen(secret)) == 0) { + return 1; + } + } + } + return 0; +} + +static void test_success(void) { + int master = -1; + pid_t child = start_prompt(&master, HEADLESS_PROMPT_SUCCESS, 0); + const char input[] = "synthetic-password\n"; + if (write(master, input, sizeof(input) - 1) != (ssize_t)(sizeof(input) - 1)) { + fail("could not write synthetic password"); + } + int echoed = secret_was_echoed(master, "synthetic-password"); + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "secure prompt success child status: %d\n", status); + fail("successful prompt child failed"); + } + if (echoed) fail("password bytes were echoed by the terminal"); + close(master); +} + +static void test_empty(void) { + int master = -1; + pid_t child = start_prompt(&master, HEADLESS_PROMPT_EMPTY, 0); + if (write(master, "\n", 1) != 1) { + fail("could not submit empty password"); + } + (void)secret_was_echoed(master, "synthetic-password"); + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fail("empty prompt child returned the wrong status"); + } + close(master); +} + +static void test_signal(int signal_number) { + int master = -1; + pid_t child = start_prompt(&master, HEADLESS_PROMPT_INTERRUPTED, signal_number); + if (kill(child, signal_number) != 0) { + fail("could not signal prompt child"); + } + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fail("prompt child did not restore and propagate its signal"); + } + close(master); +} + +static void test_overlong_input(void) { + int master = -1; + pid_t child = start_prompt(&master, HEADLESS_PROMPT_TOO_LONG, 0); + const char pattern[] = "overflow-secret"; + unsigned char input[4200]; + for (size_t index = 0; index < sizeof(input) - 1; index++) { + input[index] = (unsigned char)pattern[index % (sizeof(pattern) - 1)]; + } + input[sizeof(input) - 1] = '\n'; + size_t offset = 0; + while (offset < sizeof(input)) { + ssize_t count = write(master, input + offset, sizeof(input) - offset); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) fail("could not write overlong password"); + offset += (size_t)count; + } + int echoed = secret_was_echoed(master, pattern); + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fail("overlong prompt child returned the wrong status"); + } + if (echoed) fail("overlong password bytes were echoed by the terminal"); + close(master); +} + +static void test_continue_while_reading(void) { + int master = -1; + pid_t child = start_prompt(&master, HEADLESS_PROMPT_SUCCESS, 0); + if (kill(child, SIGCONT) != 0) { + fail("could not continue prompt child"); + } + const char input[] = "synthetic-password\n"; + if (write(master, input, sizeof(input) - 1) != (ssize_t)(sizeof(input) - 1)) { + fail("could not complete continued prompt"); + } + int echoed = secret_was_echoed(master, "synthetic-password"); + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fail("continued prompt child failed"); + } + if (echoed) fail("password bytes were echoed by the terminal"); + close(master); +} + +int main(void) { + current_test = "success"; + test_success(); + current_test = "empty"; + test_empty(); + current_test = "SIGINT"; + test_signal(SIGINT); + current_test = "SIGTERM"; + test_signal(SIGTERM); + current_test = "SIGHUP"; + test_signal(SIGHUP); + current_test = "SIGQUIT"; + test_signal(SIGQUIT); + current_test = "SIGTSTP"; + test_signal(SIGTSTP); + current_test = "SIGCONT"; + test_continue_while_reading(); + current_test = "overlong input"; + test_overlong_input(); + puts("Secure terminal prompt tests passed"); + return 0; +} diff --git a/apps/headless/build-linux.sh b/apps/headless/build-linux.sh index a79de49..d3b89af 100755 --- a/apps/headless/build-linux.sh +++ b/apps/headless/build-linux.sh @@ -22,8 +22,9 @@ trap cleanup EXIT INT TERM docker cp "$CONTAINER:/usr/local/bin/headless" build/linux/headless docker cp "$CONTAINER:/usr/local/bin/headless-host" build/linux/headless-host docker cp "$CONTAINER:/usr/local/bin/headless-mcp" build/linux/headless-mcp +docker cp "$CONTAINER:/usr/local/bin/headless-credential-broker" build/linux/headless-credential-broker docker cp "$CONTAINER:/usr/local/bin/Headless_HeadlessProtocol.resources" build/linux/Headless_HeadlessProtocol.resources -chmod 0755 build/linux/headless build/linux/headless-host build/linux/headless-mcp +chmod 0755 build/linux/headless build/linux/headless-host build/linux/headless-mcp build/linux/headless-credential-broker cp install-linux.sh build/linux/install-linux.sh cp docs/P1.md docs/P2.md build/linux/ chmod 0755 build/linux/install-linux.sh @@ -31,7 +32,7 @@ PLATFORM_LABEL="${HEADLESS_LINUX_PLATFORM:-}" PLATFORM_LABEL="${PLATFORM_LABEL##*/}" if [ -z "$PLATFORM_LABEL" ]; then PLATFORM_LABEL="$(uname -m)"; fi ARCHIVE="build/headless-linux-$PLATFORM_LABEL.tar.gz" -tar -czf "$ARCHIVE" -C build/linux headless headless-host headless-mcp Headless_HeadlessProtocol.resources install-linux.sh P1.md P2.md +tar -czf "$ARCHIVE" -C build/linux headless headless-host headless-mcp headless-credential-broker Headless_HeadlessProtocol.resources install-linux.sh P1.md P2.md echo "Linux binaries: $PWD/build/linux" echo "Linux package: $PWD/$ARCHIVE" echo "The Docker image includes Debian Chromium at /usr/lib/chromium/chromium." diff --git a/apps/headless/build.sh b/apps/headless/build.sh index c6f3bff..0bb3059 100755 --- a/apps/headless/build.sh +++ b/apps/headless/build.sh @@ -93,6 +93,7 @@ trap 'rm -rf "$SWIFT_SCRATCH"' EXIT HOST_BINARIES=() CLI_BINARIES=() MCP_BINARIES=() +BROKER_BINARIES=() RESOURCE_BUNDLE="" for arch in "${ARCHS[@]}"; do ARCH_SCRATCH="$SWIFT_SCRATCH/$arch" @@ -101,9 +102,11 @@ for arch in "${ARCHS[@]}"; do swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless-host --scratch-path "$ARCH_SCRATCH" swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless --scratch-path "$ARCH_SCRATCH" swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless-mcp --scratch-path "$ARCH_SCRATCH" + swift build "${SDK_ARGS[@]}" "${TARGET_ARGS[@]}" -c release --product headless-credential-broker --scratch-path "$ARCH_SCRATCH" HOST_BINARIES+=("$BIN_PATH/headless-host") CLI_BINARIES+=("$BIN_PATH/headless") MCP_BINARIES+=("$BIN_PATH/headless-mcp") + BROKER_BINARIES+=("$BIN_PATH/headless-credential-broker") if [[ -z "$RESOURCE_BUNDLE" ]]; then RESOURCE_BUNDLE="$BIN_PATH/Headless_HeadlessProtocol.bundle" fi @@ -127,8 +130,10 @@ copy_or_merge "$APP/Contents/MacOS/Headless" "${HOST_BINARIES[@]}" mkdir -p "$APP/Contents/Resources/bin" copy_or_merge "$APP/Contents/Resources/bin/headless" "${CLI_BINARIES[@]}" copy_or_merge "$APP/Contents/Resources/bin/headless-mcp" "${MCP_BINARIES[@]}" +copy_or_merge "$APP/Contents/Resources/bin/headless-credential-broker" "${BROKER_BINARIES[@]}" cp "$APP/Contents/Resources/bin/headless" build/bin/headless cp "$APP/Contents/Resources/bin/headless-mcp" build/bin/headless-mcp +cp "$APP/Contents/Resources/bin/headless-credential-broker" build/bin/headless-credential-broker cp -R "$RESOURCE_BUNDLE" "$APP/Contents/Resources/Headless_HeadlessProtocol.bundle" cp "$ICON" "$APP/Contents/Resources/Headless.icns" @@ -204,6 +209,7 @@ if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then fi codesign "${SIGN_ARGS[@]}" "$APP/Contents/Resources/bin/headless" codesign "${SIGN_ARGS[@]}" "$APP/Contents/Resources/bin/headless-mcp" + codesign "${SIGN_ARGS[@]}" "$APP/Contents/Resources/bin/headless-credential-broker" codesign "${SIGN_ARGS[@]}" "${APP_ENTITLEMENT_ARGS[@]}" "$APP" codesign --verify --deep --strict "$APP" echo "▸ signed as $CODESIGN_IDENTITY" @@ -214,6 +220,7 @@ else fi codesign --force --sign - "$APP/Contents/Resources/bin/headless" 2>/dev/null codesign --force --sign - "$APP/Contents/Resources/bin/headless-mcp" 2>/dev/null + codesign --force --sign - "$APP/Contents/Resources/bin/headless-credential-broker" 2>/dev/null codesign --force --sign - "$APP" 2>/dev/null fi SIZE=$(du -sh "$APP" | cut -f1) diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index b9a8933..4f3bf98 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -38,6 +38,53 @@ capabilities machine restarts. `profile clear` closes every session and permanently removes normal-profile cookies, storage, caches, and permissions. +## Credential vault + +```sh +credentials list [--origin URL] +credentials add --origin URL --alias NAME --interactive +credentials rename --origin URL --alias OLD --to NEW +credentials remove --origin URL --alias NAME +``` + +Credential commands are local-only and never enter the browser protocol or MCP. +`credentials add` reads the username and password from `/dev/tty`, hides and +confirms the password, rejects redirected standard input, and restores terminal +echo after success, failure, or a handled signal. Passwords are never accepted +in arguments or environment variables and never appear in command output. + +Origins are exact HTTPS origins with lowercase hosts and normalized default +ports. Paths, queries, fragments, embedded credentials, and public HTTP origins +are rejected. HTTP is accepted only for `localhost`, `127.0.0.1`, and `::1` +development origins. Aliases are 1-64 ASCII letters, numbers, periods, +underscores, or hyphens and are case-insensitively unique per origin. + +macOS stores passwords in the encrypted default user Keychain with an empty +trusted-app list and passphrase protection on the decryption ACL. A fresh +broker-owned native user-presence gate is added to retrieval by #157. The +vault uses no shared access group. Local/ad-hoc builds are reported as +`local-unnotarized`; rebuilds may make macOS ask again. This deliberately uses +the file-based default Keychain because Apple's biometric data-protection Keychain requires a +provisioning-profile-authorized app identity. The file-based API is deprecated, +so a future Developer ID release must migrate the same record semantics rather +than silently changing them. Linux uses the system +Secret Service through `/usr/bin/secret-tool` and the current user's validated +`/run/user/UID/bus` socket. Caller-supplied D-Bus addresses are ignored. Vault +commands are time-bounded; missing, locked, denied, timed-out, or unknown +backend failures fail closed without creating a plaintext store. Listing +reads only a private `0600` nonsecret alias index inside a `0700` directory. +Corrupt, oversized, symlinked, foreign-owned, or permissive metadata fails +closed instead of being replaced. + +Secrets are not exported or backed up by Headless; the OS vault owns its own +backup and recovery behavior. Index writes are atomic. A durable transaction +journal rolls back interrupted additions and completes interrupted deletions +on the next vault command. Renames atomically update only the nonsecret index. +Unknown future index schemas require an explicit migration. Normal-vault +aliases are unavailable to private contexts. Saved credential use and login +challenges are implemented separately by issue #157; until then these commands +manage records but do not autofill them. + ## Navigation and interaction ```sh diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index 97d0e53..46d378e 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -26,6 +26,27 @@ store. `headless profile clear` closes all sessions, clears the native profile, and opens a clean `default` session. Site logout remains the preferred way to clear one account without removing unrelated login state. +## Credential vault records + +Credential enrollment and management run through a dedicated local broker, +not the browser socket or MCP. `credentials add --interactive` reads username +and password input directly from `/dev/tty`, confirms the hidden password, and +stores only the secret in macOS Keychain or Linux Secret Service. A private, +atomic index stores the exact canonical origin, alias, and user-approved +username metadata. It never stores password values. + +The unsigned macOS tier uses the encrypted default user Keychain with an empty +trusted-app list and passphrase protection on the decryption ACL. A fresh +broker-owned native presence gate is required on every future retrieval. It +uses no shared access group. This file-based Keychain API is deprecated but, +unlike biometric data-protection Keychain access, does not require a +provisioning-profile-authorized identity. +Linux requires `/usr/bin/secret-tool` backed by the current user's validated +`/run/user/UID/bus` Secret Service socket. Caller-selected D-Bus addresses are +ignored. Both platforms fail closed when secure storage is missing, locked, +denied, timed out, or structurally unsafe. Private contexts cannot list or use +the normal vault. Credential use is intentionally deferred to #157, so this +increment cannot autofill or release a stored password. ## Acceptance workflow diff --git a/apps/headless/install-linux.sh b/apps/headless/install-linux.sh index 078ce86..f7dfd1c 100755 --- a/apps/headless/install-linux.sh +++ b/apps/headless/install-linux.sh @@ -25,9 +25,9 @@ if [ "$(uname -s)" != "Linux" ]; then exit 69 fi -if [ -x "$SCRIPT_DIR/headless" ] && [ -x "$SCRIPT_DIR/headless-host" ] && [ -x "$SCRIPT_DIR/headless-mcp" ] && [ -f "$SCRIPT_DIR/Headless_HeadlessProtocol.resources/AgentRuntime.js" ]; then +if [ -x "$SCRIPT_DIR/headless" ] && [ -x "$SCRIPT_DIR/headless-host" ] && [ -x "$SCRIPT_DIR/headless-mcp" ] && [ -x "$SCRIPT_DIR/headless-credential-broker" ] && [ -f "$SCRIPT_DIR/Headless_HeadlessProtocol.resources/AgentRuntime.js" ]; then SOURCE_DIR="$SCRIPT_DIR" -elif [ -x "$SCRIPT_DIR/build/linux/headless" ] && [ -x "$SCRIPT_DIR/build/linux/headless-host" ] && [ -x "$SCRIPT_DIR/build/linux/headless-mcp" ] && [ -f "$SCRIPT_DIR/build/linux/Headless_HeadlessProtocol.resources/AgentRuntime.js" ]; then +elif [ -x "$SCRIPT_DIR/build/linux/headless" ] && [ -x "$SCRIPT_DIR/build/linux/headless-host" ] && [ -x "$SCRIPT_DIR/build/linux/headless-mcp" ] && [ -x "$SCRIPT_DIR/build/linux/headless-credential-broker" ] && [ -f "$SCRIPT_DIR/build/linux/Headless_HeadlessProtocol.resources/AgentRuntime.js" ]; then SOURCE_DIR="$SCRIPT_DIR/build/linux" else echo "headless install: Linux binaries were not found; run ./build-linux.sh first" >&2 @@ -62,12 +62,14 @@ fi BIN_DIR="$PREFIX/bin" install -d -m 0755 "$BIN_DIR" -install -m 0755 "$SOURCE_DIR/headless" "$BIN_DIR/headless" install -m 0755 "$SOURCE_DIR/headless-host" "$BIN_DIR/headless-host" install -m 0755 "$SOURCE_DIR/headless-mcp" "$BIN_DIR/headless-mcp" +install -m 0755 "$SOURCE_DIR/headless-credential-broker" "$BIN_DIR/headless-credential-broker" install -d -m 0755 "$BIN_DIR/Headless_HeadlessProtocol.resources" install -m 0644 "$SOURCE_DIR/Headless_HeadlessProtocol.resources/AgentRuntime.js" \ "$BIN_DIR/Headless_HeadlessProtocol.resources/AgentRuntime.js" +# Activate the CLI only after its companion binaries and resources are present. +install -m 0755 "$SOURCE_DIR/headless" "$BIN_DIR/headless" echo "Headless installed in $BIN_DIR" echo "Browser runtime verified: $RUNTIME" diff --git a/apps/headless/install.sh b/apps/headless/install.sh index 77be463..8d2a023 100755 --- a/apps/headless/install.sh +++ b/apps/headless/install.sh @@ -71,6 +71,7 @@ P2.md headless headless-host headless-mcp +headless-credential-broker install-linux.sh EOF LC_ALL=C sort -u "$CONTENTS" > "$CONTENTS.sorted" diff --git a/apps/headless/test.sh b/apps/headless/test.sh index 1ea9666..aac77bd 100755 --- a/apps/headless/test.sh +++ b/apps/headless/test.sh @@ -59,11 +59,38 @@ done BIN_PATH="$(swift build "${SDK_ARGS[@]}" --scratch-path "$TEST_SCRATCH" --show-bin-path)" swift build "${SDK_ARGS[@]}" --product headless-protocol-tests --scratch-path "$TEST_SCRATCH" swift build "${SDK_ARGS[@]}" --product headless --scratch-path "$TEST_SCRATCH" +swift build "${SDK_ARGS[@]}" --product headless-credential-broker --scratch-path "$TEST_SCRATCH" swift build "${SDK_ARGS[@]}" --product headless-mcp --scratch-path "$TEST_SCRATCH" swift build "${SDK_ARGS[@]}" --product headless-mcp-tests --scratch-path "$TEST_SCRATCH" "$BIN_PATH/headless-protocol-tests" +if [[ "$(uname -s)" == "Darwin" ]]; then + cc -D_GNU_SOURCE -std=c11 -Wall -Wextra -Werror \ + -I SecurePrompt/include SecurePrompt/SecurePrompt.c Tests/secure-prompt.c \ + -o "$TEST_SCRATCH/secure-prompt-tests" +else + cc -D_GNU_SOURCE -std=c11 -Wall -Wextra -Werror \ + -I SecurePrompt/include SecurePrompt/SecurePrompt.c Tests/secure-prompt.c \ + -lutil -o "$TEST_SCRATCH/secure-prompt-tests" +fi +"$TEST_SCRATCH/secure-prompt-tests" [[ "$("$BIN_PATH/headless" --version)" == "headless $EXPECTED_VERSION" ]] || { echo "headless tests: CLI product version does not match $EXPECTED_VERSION" >&2 exit 1 } +PATH_INVOCATION_ROOT="$TEST_SCRATCH/path-invocation" +mkdir -p "$PATH_INVOCATION_ROOT" +ln -s "$BIN_PATH/headless" "$PATH_INVOCATION_ROOT/headless" +for invocation in path symlink; do + set +e + if [[ "$invocation" == "path" ]]; then + BROKER_OUTPUT="$(PATH="$PATH_INVOCATION_ROOT:/usr/bin:/bin" headless credentials list 2>&1)" + else + BROKER_OUTPUT="$("$PATH_INVOCATION_ROOT/headless" credentials list 2>&1)" + fi + set -e + if [[ "$BROKER_OUTPUT" == *"trusted headless-credential-broker executable is missing"* ]]; then + echo "headless tests: broker discovery failed for $invocation invocation" >&2 + exit 1 + fi +done "$BIN_PATH/headless-mcp-tests" "$BIN_PATH/headless-mcp" "$EXPECTED_VERSION" diff --git a/packages/headless-npm/lib/installer.mjs b/packages/headless-npm/lib/installer.mjs index 9568cb4..dc1b754 100644 --- a/packages/headless-npm/lib/installer.mjs +++ b/packages/headless-npm/lib/installer.mjs @@ -65,6 +65,7 @@ export function platformRelease(version, platform = process.platform, architectu executable: "headless", hostExecutable: "headless-host", mcpExecutable: "headless-mcp", + brokerExecutable: "headless-credential-broker", key: "linux-amd64", }; } @@ -75,6 +76,7 @@ export function platformRelease(version, platform = process.platform, architectu executable: "headless", hostExecutable: "headless-host", mcpExecutable: "headless-mcp", + brokerExecutable: "headless-credential-broker", key: "linux-arm64", }; } @@ -86,6 +88,7 @@ export function platformRelease(version, platform = process.platform, architectu executable: `${prefix}/headless`, hostExecutable: "Headless.app/Contents/MacOS/Headless", mcpExecutable: `${prefix}/headless-mcp`, + brokerExecutable: `${prefix}/headless-credential-broker`, key: `macos-${architecture}`, }; } @@ -245,6 +248,7 @@ export function validateArchiveEntries(text, kind) { "headless", "headless-host", "headless-mcp", + "headless-credential-broker", "Headless_HeadlessProtocol.resources/AgentRuntime.js", ]) { if (!seen.has(required)) throw new InstallError(`release archive is missing ${required}`, 65); @@ -254,6 +258,7 @@ export function validateArchiveEntries(text, kind) { "Headless.app/Contents/MacOS/Headless", "Headless.app/Contents/Resources/bin/headless", "Headless.app/Contents/Resources/bin/headless-mcp", + "Headless.app/Contents/Resources/bin/headless-credential-broker", ]) { if (!seen.has(required)) throw new InstallError(`release archive is missing ${required}`, 65); } @@ -293,7 +298,9 @@ async function extractArchive(archive, staging, release) { async function isUsableInstall(directory, release, version) { try { - for (const relative of [release.executable, release.hostExecutable, release.mcpExecutable]) { + for (const relative of [ + release.executable, release.hostExecutable, release.mcpExecutable, release.brokerExecutable, + ]) { const metadata = await lstat(join(directory, relative)); if (!metadata.isFile() || metadata.isSymbolicLink()) return false; } @@ -379,6 +386,7 @@ export async function ensureInstalled(options = {}) { await chmod(join(staging, release.executable), 0o755); await chmod(join(staging, release.hostExecutable), 0o755); await chmod(join(staging, release.mcpExecutable), 0o755); + await chmod(join(staging, release.brokerExecutable), 0o755); if (!(await isUsableInstall(staging, release, version))) { throw new InstallError("downloaded Headless package failed its version check", 65); } diff --git a/packages/headless-npm/test/installer.test.mjs b/packages/headless-npm/test/installer.test.mjs index 8c22571..dc7d7d9 100644 --- a/packages/headless-npm/test/installer.test.mjs +++ b/packages/headless-npm/test/installer.test.mjs @@ -27,7 +27,9 @@ let servedManifest; before(async () => { mkdirSync(join(fixture, "Headless_HeadlessProtocol.resources"), { recursive: true }); - for (const executable of ["headless", "headless-host", "headless-mcp", "install-linux.sh"]) { + for (const executable of [ + "headless", "headless-host", "headless-mcp", "headless-credential-broker", "install-linux.sh", + ]) { const body = executable === "headless" ? `#!/bin/sh\nif [ "$1" = --version ]; then echo 'headless ${version}'; else echo wrapper-ok; fi\n` : "#!/bin/sh\nexit 0\n"; @@ -43,6 +45,7 @@ before(async () => { "headless", "headless-host", "headless-mcp", + "headless-credential-broker", "install-linux.sh", "Headless_HeadlessProtocol.resources", ], { encoding: "utf8" });