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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,23 @@ On macOS, agent startup opens visible browser windows behind the app currently
in use. Change the persistent default with `headless config set
startup-presentation foreground` or restore background startup with `headless
config set startup-presentation background`; inspect it with `headless config
get startup-presentation`. `headless start --foreground` and `headless start
get startup-presentation`. Use `config list` to discover settings, `config
describe KEY` for type and policy metadata, and `config reset KEY` to restore a
built-in default. `headless start --foreground` and `headless start
--background` are one-launch overrides. Settings and overrides apply only when
launching a new host and do not reorder an already-running host.

Settings declare their value type, default, supported platforms, access class,
and when changes take effect. `agent-readable` settings can be inspected but
not changed by an agent, `agent-writable` settings can also be changed, and
`user-only` settings are omitted from every agent CLI operation. The registry
is local-only and is not available through MCP or the browser protocol. macOS
retains the existing `com.headless.app` / `AgentStartupPresentation` preference;
Linux uses a bounded, versioned file in a private XDG configuration directory.
Security invariants such as sandboxing, navigation restrictions, diagnostic
gates, download denial, and the absence of arbitrary JavaScript and TCP control
are fixed policy, not settings.

Inspection is progressively disclosed instead of forcing an entire page into an
agent prompt. Start with `--context summary`, use `--context outline` to receive
structural region references such as `@r4`, then inspect only that region with
Expand Down
93 changes: 28 additions & 65 deletions apps/headless/Sources/HeadlessCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,52 +21,6 @@ private func printResponse(_ response: CommandResponse) throws {
FileHandle.standardOutput.write(try ProtocolCodec.encodeLine(response))
}

private enum StartupPresentationPreference {
static let builtInDefault = AgentStartupPresentation.background
private static let domain = "com.headless.app"
private static let key = "AgentStartupPresentation"

static var configured: AgentStartupPresentation? {
guard let defaults = UserDefaults(suiteName: domain),
let value = defaults.string(forKey: key) else { return nil }
return AgentStartupPresentation(rawValue: value)
}

static var effective: AgentStartupPresentation {
configured ?? builtInDefault
}

static func requireSupportedPlatform() throws {
#if !os(macOS)
throw StartupPresentationPreferenceError.unsupported
#endif
}

static func set(_ presentation: AgentStartupPresentation) throws {
guard let defaults = UserDefaults(suiteName: domain) else {
throw StartupPresentationPreferenceError.unavailable
}
defaults.set(presentation.rawValue, forKey: key)
guard defaults.synchronize() else {
throw StartupPresentationPreferenceError.writeFailed
}
}
}

private enum StartupPresentationPreferenceError: Error, Equatable, CustomStringConvertible {
case unsupported
case unavailable
case writeFailed

var description: String {
switch self {
case .unsupported: return "Startup presentation preferences are supported only on macOS."
case .unavailable: return "Could not open the Headless preferences domain."
case .writeFailed: return "Could not persist the startup presentation preference."
}
}
}

private struct HostLauncher {
let client = LocalSocketClient()

Expand All @@ -76,7 +30,7 @@ private struct HostLauncher {

func start(presentation: AgentStartupPresentation? = nil) throws -> CommandResponse {
#if !os(macOS)
if presentation != nil { throw StartupPresentationPreferenceError.unsupported }
if presentation != nil { throw SettingsError.unsupportedPlatform("startup-presentation") }
#endif
if let response = ping(), response.ok { return response }
#if os(Linux)
Expand All @@ -91,7 +45,11 @@ private struct HostLauncher {
var environment = ProcessInfo.processInfo.environment
environment["HEADLESS_AGENT_HOST"] = "1"
#if os(macOS)
let effectivePresentation = presentation ?? StartupPresentationPreference.effective
let configuredPresentation = try SettingsStore.production().effectiveRawValue("startup-presentation")
guard let storedPresentation = AgentStartupPresentation(rawValue: configuredPresentation) else {
throw SettingsError.corruptStorage
}
let effectivePresentation = presentation ?? storedPresentation
#else
let effectivePresentation = AgentStartupPresentation.background
#endif
Expand Down Expand Up @@ -251,21 +209,20 @@ do {
#endif
case .start(let presentation):
try printResponse(try HostLauncher().start(presentation: presentation))
case .getStartupPresentation:
try StartupPresentationPreference.requireSupportedPlatform()
let configured = StartupPresentationPreference.configured
printJSON(.object([
"builtInDefault": .string(StartupPresentationPreference.builtInDefault.rawValue),
"configured": configured.map { .string($0.rawValue) } ?? .null,
"startupPresentation": .string(StartupPresentationPreference.effective.rawValue),
]))
case .setStartupPresentation(let presentation):
try StartupPresentationPreference.requireSupportedPlatform()
try StartupPresentationPreference.set(presentation)
printJSON(.object([
"startupPresentation": .string(presentation.rawValue),
"takesEffect": .string("next-host-start"),
]))
case .config(let command):
let settings = try SettingsStore.production()
switch command {
case .list:
printJSON(try settings.list())
case .describe(let key):
printJSON(try settings.describe(key))
case .get(let key):
printJSON(try settings.get(key))
case .set(let key, let value):
printJSON(try settings.set(key, rawValue: value))
case .reset(let key):
printJSON(try settings.reset(key))
}
case .credentials(let command):
try CredentialBrokerLauncher().run(command)
}
Expand Down Expand Up @@ -313,10 +270,16 @@ do {
)
try? printResponse(response)
exit(69)
} catch let error as StartupPresentationPreferenceError {
} catch let error as SettingsError {
let code: String
switch error {
case .unsupportedPlatform: code = "UNSUPPORTED_CAPABILITY"
case .unknownKey, .invalidValue, .accessDenied: code = "INVALID_CONFIGURATION"
case .insecureStorage, .corruptStorage, .operationFailed: code = "CONFIGURATION_FAILED"
}
let response = CommandResponse.failure(
id: "unknown",
code: error == .unsupported ? "UNSUPPORTED_CAPABILITY" : "CONFIGURATION_FAILED",
code: code,
message: error.description
)
try? printResponse(response)
Expand Down
42 changes: 28 additions & 14 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import Foundation

public enum AgentStartupPresentation: String, Equatable, Sendable {
public enum AgentStartupPresentation: String, CaseIterable, Equatable, Sendable {
case background
case foreground
}

public enum ConfigCLICommand: Equatable, Sendable {
case list
case describe(String)
case get(String)
case set(key: String, value: String)
case reset(String)
}

public enum LocalCommand: Equatable, Sendable {
case help
case version
case capabilities
case runtime
case start(presentation: AgentStartupPresentation?)
case getStartupPresentation
case setStartupPresentation(AgentStartupPresentation)
case config(ConfigCLICommand)
case credentials(CredentialCLICommand)
}

Expand Down Expand Up @@ -104,16 +111,20 @@ public struct CLIParser {
throw CLIParseError.invalidOption(arguments.first ?? "start")
}
case "config":
guard session == nil else { throw CLIParseError.invalidOption("--session") }
switch arguments {
case ["get", "startup-presentation"]:
return CLIInvocation(local: .getStartupPresentation, jsonOutput: true)
case let values where values.count == 3
&& values[0] == "set" && values[1] == "startup-presentation":
let value = values[2]
guard let presentation = AgentStartupPresentation(rawValue: value) else {
throw CLIParseError.invalidOption(value)
}
return CLIInvocation(local: .setStartupPresentation(presentation), jsonOutput: true)
case ["list"]:
return CLIInvocation(local: .config(.list), jsonOutput: true)
case let values where values.count == 2 && values[0] == "describe":
return CLIInvocation(local: .config(.describe(values[1])), jsonOutput: true)
case let values where values.count == 2 && values[0] == "get":
return CLIInvocation(local: .config(.get(values[1])), jsonOutput: true)
case let values where values.count == 3 && values[0] == "set":
return CLIInvocation(
local: .config(.set(key: values[1], value: values[2])), jsonOutput: true
)
case let values where values.count == 2 && values[0] == "reset":
return CLIInvocation(local: .config(.reset(values[1])), jsonOutput: true)
default:
throw CLIParseError.invalidOption(arguments.first ?? "config")
}
Expand Down Expand Up @@ -733,8 +744,8 @@ Commands:
version | --version
start [--background|--foreground] | status | stop | runtime
profile clear
config get startup-presentation
config set startup-presentation background|foreground
config list | config describe KEY | config get KEY
config set KEY VALUE | config reset KEY
credentials list [--origin URL]
credentials add --origin URL --alias NAME --interactive
credentials rename --origin URL --alias OLD --to NEW
Expand Down Expand Up @@ -775,4 +786,7 @@ Commands:
Global options:
--session NAME target a named browser session
-- stop parsing global options; quote multi-word fill values

Settings:
\(SettingsRegistry.shared.helpLines.joined(separator: "\n"))
"""
11 changes: 11 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,19 @@ public let capabilitiesDocument: JSONValue = {
]),
"screenshotSeries": stringArray(["viewport", "section"]),
"localCommands": stringArray([
"config.describe", "config.get", "config.list", "config.reset", "config.set",
"credentials.add", "credentials.list", "credentials.remove", "credentials.rename",
]),
"settings": .object([
"definitions": .array(SettingsRegistry.shared.definitions.compactMap { definition in
definition.access == .userOnly ? nil : definition.document
}),
"storage": .object([
"macos": .string("user-defaults"),
"linux": .string("private-xdg-config-file"),
]),
"securityInvariantsConfigurable": .bool(false),
]),
"credentialVault": .object([
"supported": .bool(true),
"backend": .string(credentialBackend),
Expand Down
Loading