diff --git a/.agents/skills/headless-computer-use/SKILL.md b/.agents/skills/headless-computer-use/SKILL.md index 0071cc9..70d2c3d 100644 --- a/.agents/skills/headless-computer-use/SKILL.md +++ b/.agents/skills/headless-computer-use/SKILL.md @@ -21,8 +21,8 @@ controls the desktop, native applications, OS chrome, microphone, or system audi selection, Docker access, visible-browser mode, or MCP configuration matters. Run `headless capabilities` once before relying on optional features. Network -emulation and mocking are Linux Chromium capabilities; macOS WebKit reports them -as unsupported. +emulation, request mocking, and file upload are Linux Chromium capabilities; +macOS WebKit reports them as unsupported. ## Follow the mandatory interaction loop diff --git a/.agents/skills/headless-computer-use/references/commands.md b/.agents/skills/headless-computer-use/references/commands.md index 8560561..a299ee1 100644 --- a/.agents/skills/headless-computer-use/references/commands.md +++ b/.agents/skills/headless-computer-use/references/commands.md @@ -30,6 +30,8 @@ headless --session NAME click REF headless --session NAME click --role ROLE --name NAME headless --session NAME fill REF "TEXT" headless --session NAME fill REF -- "--json stays literal" +headless --session NAME upload REF --artifact FILE +headless --session NAME upload --role textbox --name NAME --artifact FILE headless --session NAME press KEY headless --session NAME scroll up|down|top|bottom --amount PIXELS headless --session NAME back @@ -44,7 +46,9 @@ large pages, request `outline`, select a returned `@rN` region, then use bound the result; check `omitted` before assuming it describes the whole page. Use `click --role ... --name ...` for unique accessible controls. Use an `@eN` ref from the latest inspection when role/name is ambiguous. Inspect again after -navigation or a large rerender. +navigation or a large rerender. File inputs advertise `upload` for an existing +private artifact-store basename. Upload never accepts or imports a filesystem +path. Ask before uploading, as in [safety.md](safety.md). Pass fill text as one quoted shell argument so whitespace is preserved. Put `--` before a value that contains a literal global flag such as `--json` or @@ -78,7 +82,8 @@ headless artifacts list ``` Single artifact output names are basenames ending in `.png`, `.jpg`, `.jpeg`, -`.pdf`, `.mp4`, `.mov`, `.webm`, `.gif`, or `.json`. Screenshot series output +`.gif`, `.webp`, `.txt`, `.csv`, `.pdf`, `.mp4`, `.mov`, `.webm`, or `.json`. +Screenshot series output uses a safe prefix and creates numbered PNG/JPG artifacts. Headless refuses paths and overwrites. Built-in recording captures browser pixels only. diff --git a/.agents/skills/headless-computer-use/references/safety.md b/.agents/skills/headless-computer-use/references/safety.md index 18b17c4..c0a7f6a 100644 --- a/.agents/skills/headless-computer-use/references/safety.md +++ b/.agents/skills/headless-computer-use/references/safety.md @@ -32,6 +32,13 @@ publishes content, makes a purchase, changes permissions, accepts legal terms, uploads a file, or otherwise creates a meaningful external effect not already explicitly authorized by the user. +To attach a file after that confirmation, use +`headless upload --role textbox --name NAME --artifact FILE` (or `upload @eN +--artifact FILE`) with an existing private artifact-store basename. No +agent-facing command imports a local filesystem path. Downloads remain denied. +File bytes never appear on the protocol socket. macOS WebKit returns +`UNSUPPORTED_CAPABILITY` until a native attach path exists. + Routine mutations inside an explicitly requested disposable/local E2E test are in scope. Do not transfer that authorization to a production site. diff --git a/README.md b/README.md index f0bedf3..ab211f6 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,8 @@ structural region references such as `@r4`, then inspect only that region with `contextStats` make pruning explicit. `--context full --text` remains the explicit broad-page escape hatch. -Each control's `actions` list contains only protocol verbs that can run (`click` -or `fill`); unsupported controls never advertise nonexistent commands. Full +Each control's `actions` list contains only protocol verbs that can run (`click`, +`fill`, or `upload`); unsupported controls never advertise nonexistent commands. Full inspection retains roles, names, rendered media metadata, safety markers, bounds, and element references such as `@e1`. `capture-info` returns the browser surface, page state, action trace, and diff --git a/SECURITY.md b/SECURITY.md index a153d02..31a0473 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,7 @@ These are host-enforced contracts. Anything that defeats one is in scope: | **Navigation** | HTTP/HTTPS only. Optional `headless start --allow` host allowlist. `file:`, `javascript:`, `data:`, credential-bearing URLs, and external application schemes must be refused at every layer. | | **Downloads** | Page-initiated downloads are denied. Executables, installers, scripts, libraries, and disk images are blocked by extension. | | **Control plane** | A `0600` Unix socket inside a `0700` per-user directory, with a peer-UID check. There is no TCP listener and no Chromium debug port. Any remote reachability is a vulnerability. | -| **Artifacts** | Bare validated names, `O_EXCL` creation at `0600` inside a `0700` root, never overwritten. Path traversal or reading outside the store is a vulnerability. | +| **Artifacts** | Bare validated names, `O_EXCL` creation at `0600` inside a `0700` root, never overwritten. Path traversal or reading outside the store is a vulnerability. Upload accepts only an existing store basename; agent-facing surfaces cannot ingest local files. | | **Secrets** | Cookie and storage _values_ require both `--values` and `HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1`. Authorization, cookie, token, and secret headers, plus URL credentials, are always redacted. Flow recordings never contain typed values. | | **Untrusted content** | Everything derived from a page is marked `untrustedContent` and is never executed as a command. A page that induces the host to act on its own text is a vulnerability. | | **Sandbox** | The Linux host refuses to run as root and never passes `--no-sandbox`. Snap Chromium is rejected before launch. | diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index ffb0264..debf6e4 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -465,7 +465,10 @@ extension BrowserWindowController { // WKWebView can expose its initial about:blank document before // document-start scripts run. Install once in that document, then // subsequent calls use the cached isolated-world runtime. - return try evaluate(agentRuntimeJavaScript + "\n" + agentEvaluationBody(body)) + return try evaluate( + "globalThis.__headlessFileUpload = false;\n" + agentRuntimeJavaScript + + "\n" + agentEvaluationBody(body) + ) } } } diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index aebce42..72b0f99 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -6,6 +6,8 @@ import Darwin import Glibc #endif +private let linuxAgentRuntimeJavaScript = "globalThis.__headlessFileUpload = true;\n" + agentRuntimeJavaScript + private final class ChromiumChildProcess { let processIdentifier: Int32 private let lock = NSLock() @@ -629,9 +631,10 @@ final class LinuxBrowserSession: @unchecked Sendable { self.connection = connection _ = try command("Page.enable") _ = try command("Runtime.enable") + _ = try command("DOM.enable") _ = try command("Log.enable") _ = try command("Page.addScriptToEvaluateOnNewDocument", parameters: [ - "source": agentRuntimeJavaScript, + "source": linuxAgentRuntimeJavaScript, "worldName": "HeadlessAgent", "runImmediately": true, ]) @@ -750,6 +753,24 @@ final class LinuxBrowserSession: @unchecked Sendable { ]) } + func upload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue { + let args = try browserTargetArguments(parameters) + let objectId = try evaluateNode( + "return globalThis.__headlessAgent.fileInput(args);", + input: ["args": args] + ) + defer { _ = try? command("Runtime.releaseObject", parameters: ["objectId": objectId]) } + guard case .object(var metadata) = try fileInputMetadata(objectId: objectId) else { + throw CDPError.invalidResponse("file input metadata") + } + _ = try command("DOM.setFileInputFiles", parameters: [ + "objectId": objectId, + "files": [artifactURL.path], + ]) + metadata["artifact"] = .string(artifactURL.lastPathComponent) + return .object(metadata) + } + func fill(parameters: [String: JSONValue]) throws -> JSONValue { guard let value = parameters["value"]?.stringValue else { throw CDPError.commandFailed("missing value") } let target = try trustedInputTarget(parameters: parameters, action: "fill") @@ -1205,6 +1226,74 @@ final class LinuxBrowserSession: @unchecked Sendable { ] } + /// Runtime.evaluate with returnByValue false so a DOM node keeps its + /// objectId for `DOM.setFileInputFiles`. The existing `evaluate` helper + /// always returns JSON and cannot yield a node handle. + private func evaluateNode(_ body: String, input: [String: Any] = [:]) throws -> String { + let inputData = try JSONSerialization.data(withJSONObject: input, options: [.sortedKeys]) + guard let inputJSON = String(data: inputData, encoding: .utf8) else { + throw CDPError.invalidResponse("input encoding") + } + let expression = """ + (() => { + const __input = \(inputJSON); + const args = __input.args; + \(body) + })() + """ + func evaluateParameters() throws -> [String: Any] { + [ + "expression": expression, + "returnByValue": false, + "userGesture": true, + "contextId": try isolatedExecutionContextID(), + ] + } + let response: [String: Any] + do { + response = try command("Runtime.evaluate", parameters: try evaluateParameters()) + } catch let error as CDPError where isTransientNavigationContext(error) { + clearIsolatedContext() + response = try command("Runtime.evaluate", parameters: try evaluateParameters()) + } + if let exception = response["exceptionDetails"] as? [String: Any] { + throw hostError(fromCDPException: exception) + } + guard let result = response["result"] as? [String: Any], + result["subtype"] as? String == "node", + let objectId = result["objectId"] as? String, !objectId.isEmpty else { + throw CDPError.invalidResponse("file input objectId") + } + return objectId + } + + private func fileInputMetadata(objectId: String) throws -> JSONValue { + let response = try command("Runtime.callFunctionOn", parameters: [ + "objectId": objectId, + "functionDeclaration": "function() { return globalThis.__headlessAgent.fileInputMetadata(this); }", + "returnByValue": true, + ]) + if let exception = response["exceptionDetails"] as? [String: Any] { + throw hostError(fromCDPException: exception) + } + guard let result = response["result"] as? [String: Any], let value = result["value"] else { + throw CDPError.invalidResponse("file input metadata") + } + return try JSONValue.foundationValue(value) + } + + private func hostError(fromCDPException exception: [String: Any]) -> HostError { + let description = ((exception["exception"] as? [String: Any])?["description"] as? String) + ?? (exception["text"] as? String) + ?? "Browser operation failed" + let firstLine = description.split(whereSeparator: \.isNewline).first.map(String.init) ?? description + let trimmed = firstLine.hasPrefix("Error: ") ? String(firstLine.dropFirst(7)) : firstLine + let codeText = trimmed.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + .first.map(String.init) ?? "" + let code = HostErrorCode(rawValue: codeText) ?? .operationFailed + return HostError(code: code, message: String(decoding: trimmed.utf8.prefix(4_096), as: UTF8.self)) + } + private func evaluate(_ body: String, input: [String: Any] = [:], timeout: TimeInterval = 10) throws -> JSONValue { let timeoutMilliseconds = Int32(min(125_000, max(1, ceil(timeout * 1_000)))) let inputData = try JSONSerialization.data(withJSONObject: input, options: [.sortedKeys]) @@ -1291,7 +1380,7 @@ final class LinuxBrowserSession: @unchecked Sendable { let installedValue = (installed["result"] as? [String: Any])?["value"] as? Bool ?? false if !installedValue { _ = try command("Runtime.evaluate", parameters: [ - "expression": agentRuntimeJavaScript, + "expression": linuxAgentRuntimeJavaScript, "returnByValue": true, "contextId": identifier, ]) diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index 82d7a12..f7a6bcc 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -87,6 +87,9 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession { func hostFill(parameters: [String: JSONValue]) throws -> JSONValue { try browserSession.fill(parameters: parameters) } + func hostUpload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue { + try browserSession.upload(parameters: parameters, artifactURL: artifactURL) + } func hostPress(parameters: [String: JSONValue]) throws -> JSONValue { try browserSession.press(parameters: parameters) } diff --git a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift index 2bab394..3d5b488 100644 --- a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift +++ b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift @@ -207,7 +207,7 @@ public final class ArtifactStore: @unchecked Sendable { private static let listedExtensions: Set = ScreenshotFormat.artifactExtensions .union(RecordingFormat.artifactExtensions) - .union(["json"]) + .union(uploadArtifactExtensions) private func metadata(for url: URL) throws -> JSONValue { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) @@ -222,6 +222,26 @@ public final class ArtifactStore: @unchecked Sendable { ]) } + /// Resolves an already-stored upload artifact to its on-disk URL. Callers + /// receive a path inside this store only, never an agent-supplied path. + public func urlForExistingArtifact( + name: String, allowedExtensions: Set = uploadArtifactExtensions + ) throws -> URL { + lock.lock(); defer { lock.unlock() } + do { try validateArtifactName(name, expectedExtensions: allowedExtensions) } + catch { throw ArtifactError.invalidName(name) } + let url = rootURL.appendingPathComponent(name, isDirectory: false) + guard url.deletingLastPathComponent().standardizedFileURL == rootURL.standardizedFileURL else { + throw ArtifactError.invalidName(name) + } + var info = stat() + guard lstat(url.path, &info) == 0 else { throw ArtifactError.missing(name) } + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw ArtifactError.writeFailed("Artifact is not a permitted regular file") + } + return url + } + /// Reads only a regular artifact owned by this store. Callers never receive /// a path supplied by the agent, preventing an artifact command from /// becoming an arbitrary-file read primitive. diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index c3ca91f..d971af5 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -143,6 +143,8 @@ public struct CLIParser { return try parseInspect(arguments, session: session, jsonOutput: jsonOutput) case "click": return try parseTargeted(.click, arguments: arguments, session: session, jsonOutput: jsonOutput) + case "upload": + return try parseUpload(arguments, session: session, jsonOutput: jsonOutput) case "fill": guard arguments.count == 2 else { throw CLIParseError.missingArgument("TARGET TEXT") } return remote(.fill, session: session, parameters: [ @@ -367,6 +369,21 @@ public struct CLIParser { return number } + private func parseUpload( + _ arguments: [String], session: String?, jsonOutput: Bool + ) throws -> CLIInvocation { + var args = arguments + let artifact = try removeOption("--artifact", from: &args) + guard let artifact else { throw CLIParseError.missingArgument("--artifact") } + try validateArtifactName(artifact, expectedExtensions: uploadArtifactExtensions) + let invocation = try parseTargeted( + .upload, arguments: args, session: session, jsonOutput: jsonOutput + ) + var parameters = invocation.request?.parameters ?? [:] + parameters["artifact"] = .string(artifact) + return remote(.upload, session: session, parameters: parameters, jsonOutput: jsonOutput) + } + private func parseTargeted( _ command: CommandName, arguments: [String], @@ -808,6 +825,7 @@ Commands: [--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text] click REF | click --role ROLE [--name NAME] fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY + upload REF --artifact FILE | upload --role ROLE [--name NAME] --artifact FILE scroll [up|down|top|bottom] [--amount PX] back | reload wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS] diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index c0b0b33..ab0d9df 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -25,6 +25,7 @@ public struct BrowserEngineCapabilities: Sendable { public let screenshotClipboard: Bool public let inputDispatch: String public let normalProfileStorage: String + public let fileUpload: Bool public var supportedCommands: [CommandName] { CommandName.allCases.filter { !unsupportedCommands.contains($0) } @@ -67,6 +68,7 @@ public struct BrowserEngineCapabilities: Sendable { "screenshotClipboard": .bool(screenshotClipboard), "tourTimeoutMs": .number(65_000), "inputDispatch": .string(inputDispatch), + "fileUpload": .bool(fileUpload), "normalProfile": .object([ "persistent": .bool(true), "sharedAcrossSessions": .bool(true), @@ -98,7 +100,7 @@ public struct BrowserEngineCapabilities: Sendable { public static let webkit = BrowserEngineCapabilities( engine: .webkit, platforms: ["macos"], - unsupportedCommands: [.networkEmulate, .networkMockSet, .networkMockClear], + unsupportedCommands: [.networkEmulate, .networkMockSet, .networkMockClear, .upload], pdfOutput: "rasterized-page-image", elementScreenshotCoordinates: "viewport", elementScreenshotBeyondViewport: false, @@ -111,7 +113,8 @@ public struct BrowserEngineCapabilities: Sendable { qaDiagnosticSynchronization: "best-effort-page-world-observer", screenshotClipboard: true, inputDispatch: "synthetic-dom", - normalProfileStorage: "persistent-wkwebsite-data-store" + normalProfileStorage: "persistent-wkwebsite-data-store", + fileUpload: false ) public static let chromium = BrowserEngineCapabilities( @@ -133,7 +136,8 @@ public struct BrowserEngineCapabilities: Sendable { qaDiagnosticSynchronization: "runtime-round-trip-flush", screenshotClipboard: false, inputDispatch: "trusted-cdp", - normalProfileStorage: "private-xdg-data-directory" + normalProfileStorage: "private-xdg-data-directory", + fileUpload: true ) public static func profile(for engine: BrowserEngineName) -> BrowserEngineCapabilities { @@ -175,13 +179,17 @@ public let capabilitiesDocument: JSONValue = { let credentialBackend = "linux-secret-service" let credentialSecurityTier = "os-secure-store" #endif + let artifactExtensions = ScreenshotFormat.artifactExtensions + .union(RecordingFormat.artifactExtensions) + .union(uploadArtifactExtensions) + .sorted() return .object([ "protocolVersion": .string(headlessProtocolVersion), "transport": stringArray(["local-unix-socket"]), "currentEngine": .string(currentBrowserEngineCapabilities.engine.rawValue), "commands": .array(CommandName.allCases.map { .string($0.rawValue) }), "engines": .object(engines), - "artifacts": stringArray((screenshotExtensions + recordingExtensions + ["json"]).sorted()), + "artifacts": stringArray(artifactExtensions), "screenshotFormats": stringArray(screenshotExtensions), "pdfScreenshots": .string("full-page only"), "screenshotClipboard": .string("macOS image screenshots only"), diff --git a/apps/headless/Sources/HeadlessProtocol/Flows.swift b/apps/headless/Sources/HeadlessProtocol/Flows.swift index 13d4cbe..dd2b09f 100644 --- a/apps/headless/Sources/HeadlessProtocol/Flows.swift +++ b/apps/headless/Sources/HeadlessProtocol/Flows.swift @@ -23,7 +23,7 @@ public struct RecordedFlowStep: Codable, Sendable { } public let replayableFlowCommands: Set = [ - .visit, .click, .press, .scroll, .back, .reload, .wait, .tour, + .visit, .click, .press, .scroll, .back, .reload, .wait, .tour, .upload, ] public func flowStepIfSafe(command: CommandName, parameters: [String: JSONValue]) -> RecordedFlowStep? { diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index 290eade..c2a304e 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -50,6 +50,7 @@ public protocol BrowserEngineSession: AnyObject { func hostPromptCredentialSave(origin: CredentialOrigin, account: String) throws -> CredentialAlias? func hostFillCredential(form: AuthenticationForm, credential: AuthenticationCredential) throws -> JSONValue func hostFinishCredentialProtection(form: AuthenticationForm) + func hostUpload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue } public extension BrowserEngineSession { @@ -101,6 +102,13 @@ public extension BrowserEngineSession { message: "Request mocking requires the Chromium CDP engine." ) } + + func hostUpload(parameters _: [String: JSONValue], artifactURL _: URL) throws -> JSONValue { + throw HostError( + code: .unsupportedCapability, + message: "File upload requires an engine that can attach files without page JavaScript." + ) + } } public protocol BrowserEngine: AnyObject { @@ -444,6 +452,18 @@ public final class HostCore: @unchecked Sendable { case .inspect: return try session.hostInspect(parameters: request.parameters) case .click: return try session.hostClick(parameters: request.parameters) case .fill: return try session.hostFill(parameters: request.parameters) + case .upload: + guard let artifact = request.parameters["artifact"]?.stringValue else { + throw HostError(code: .missingParameter, message: "Artifact name is required.") + } + let url = try artifacts.urlForExistingArtifact(name: artifact) + var result = try session.hostUpload(parameters: request.parameters, artifactURL: url) + if case .object(var object) = result { + object["artifact"] = .string(artifact) + object.removeValue(forKey: "path") + result = .object(object) + } + return result case .press: return try session.hostPress(parameters: request.parameters) case .scroll: return try session.hostScroll(parameters: request.parameters) case .wait: return try session.hostWait(parameters: request.parameters) diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index b42d200..f61f87a 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -64,6 +64,7 @@ public enum CommandName: String, Codable, CaseIterable, Sendable { case inspect case click case fill + case upload case press case scroll case back @@ -271,6 +272,12 @@ public struct CommandRequest: Codable, Equatable, Sendable { try target(allowValue: false) case .fill: try target(allowValue: true) + case .upload: + try target(allowValue: false, validateAllowedKeys: false) + try allow(["target", "role", "name", "artifact"]) + if let artifact = try string("artifact", required: true, maximumBytes: 128) { + try validateArtifactName(artifact, expectedExtensions: uploadArtifactExtensions) + } case .press: try allow(["key"]) _ = try string("key", required: true, maximumBytes: 32) @@ -534,6 +541,10 @@ public func validateIdentifier(_ value: String, field: String) throws { } } +public let uploadArtifactExtensions: Set = [ + "csv", "gif", "jpeg", "jpg", "json", "pdf", "png", "txt", "webp", +] + public func validateArtifactName(_ value: String, expectedExtension: String) throws { try validateArtifactName(value, expectedExtensions: [expectedExtension]) } diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index 4d6873a..9d7da88 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -86,11 +86,39 @@ if (!globalThis.__headlessAgent) { }; const visible = element => { if (!(element instanceof Element) || !element.isConnected) return false; - const style = getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false; + for (let current = element; current instanceof Element; current = current.parentElement) { + const style = getComputedStyle(current); + const opacity = Number.parseFloat(style.opacity); + if (style.display === 'none' || style.visibility === 'hidden' || (Number.isFinite(opacity) && opacity <= 0)) return false; + } const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; + const uploadVisible = element => { + if (!visible(element)) return false; + const viewportWidth = document.documentElement.clientWidth || globalThis.innerWidth || 0; + const viewportHeight = document.documentElement.clientHeight || globalThis.innerHeight || 0; + let rect = element.getBoundingClientRect(); + let left = Math.max(0, rect.left); + let top = Math.max(0, rect.top); + let right = Math.min(viewportWidth, rect.right); + let bottom = Math.min(viewportHeight, rect.bottom); + for (let current = element.parentElement; current instanceof Element; current = current.parentElement) { + const style = getComputedStyle(current); + if (!/(hidden|clip)/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)) continue; + rect = current.getBoundingClientRect(); + left = Math.max(left, rect.left); + top = Math.max(top, rect.top); + right = Math.min(right, rect.right); + bottom = Math.min(bottom, rect.bottom); + } + if (right <= left || bottom <= top) return false; + if (typeof document.elementFromPoint === 'function') { + const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2); + if (hit && hit !== element && !element.contains(hit)) return false; + } + return true; + }; const role = element => { const explicit = element.getAttribute('role'); if (explicit) return explicit.split(/\s+/)[0].toLowerCase().slice(0, 64); @@ -115,21 +143,25 @@ if (!globalThis.__headlessAgent) { const hints = []; const tag = element.tagName.toLowerCase(); const elementRole = role(element); - if (tag === 'a' && element.hasAttribute('href')) hints.push('click'); - if (tag === 'button' || elementRole === 'button') hints.push('click'); - if (tag === 'summary' || elementRole === 'tab' || elementRole === 'menuitem') hints.push('click'); // Only advertise verbs implemented by the public Headless protocol. // Unsupported controls can still appear for context, but must not route - // an agent toward nonexistent select/upload/slide commands. - if (element instanceof HTMLTextAreaElement || element.isContentEditable) hints.push('fill'); + // an agent toward nonexistent select/slide commands. File inputs advertise + // upload only when the host injected __headlessFileUpload, never fill or + // click as the primary verb. if (element instanceof HTMLInputElement) { const type = (element.getAttribute('type') || 'text').toLowerCase(); - if (type === 'file') return hints; - else if (['checkbox', 'radio'].includes(type)) hints.push('click'); + if (type === 'file') { + if (globalThis.__headlessFileUpload === true) hints.push('upload'); + return Array.from(new Set(hints)); + } else if (['checkbox', 'radio'].includes(type)) hints.push('click'); else if (type === 'range') hints.push('fill'); else if (['button', 'submit', 'reset', 'image'].includes(type)) hints.push('click'); else hints.push('fill'); } + if (tag === 'a' && element.hasAttribute('href')) hints.push('click'); + if (tag === 'button' || elementRole === 'button') hints.push('click'); + if (tag === 'summary' || elementRole === 'tab' || elementRole === 'menuitem') hints.push('click'); + if (element instanceof HTMLTextAreaElement || element.isContentEditable) hints.push('fill'); if (element.tabIndex >= 0 && hints.length === 0) hints.push('click'); return Array.from(new Set(hints)); }; @@ -671,6 +703,30 @@ if (!globalThis.__headlessAgent) { if (!hit || (hit !== element && !element.contains(hit))) throw new Error('ELEMENT_OBSCURED'); return {ref: refFor(element), role: role(element), name: name(element), x, y}; }; + const checkedFileInput = element => { + const type = element instanceof HTMLInputElement + ? String(element.getAttribute('type') || '').toLowerCase() + : ''; + if (!(element instanceof HTMLInputElement) || type !== 'file') { + fail('ELEMENT_NOT_FOUND', 'ELEMENT_NOT_FOUND: target is not a file input'); + } + if (element.disabled || element.getAttribute('aria-disabled') === 'true') { + fail('NOT_EDITABLE', 'NOT_EDITABLE: file input is disabled'); + } + if (!uploadVisible(element)) { + fail('ELEMENT_NOT_VISIBLE', 'ELEMENT_NOT_VISIBLE: file input is not visible'); + } + return element; + }; + const fileInput = args => checkedFileInput(target(args)); + const fileInputMetadata = element => { + const checked = checkedFileInput(element); + return { + uploaded: refFor(checked), + role: role(checked), + name: name(checked), + }; + }; const fill = args => { const element = target(args); if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable)) { @@ -980,8 +1036,8 @@ if (!globalThis.__headlessAgent) { return {count: document.getAnimations().length, animations: all, truncated: document.getAnimations().length > all.length}; }; return { - snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, authentication, scroll, state, tour, screenshotPlan, - scrollToCapturePoint, rectangle, styles, storage, + snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputMetadata, + authentication, scroll, state, tour, screenshotPlan, scrollToCapturePoint, rectangle, styles, storage, performance: performanceSummary, animations }; })(); diff --git a/apps/headless/Tests/Fixtures/file-upload.html b/apps/headless/Tests/Fixtures/file-upload.html new file mode 100644 index 0000000..ec62842 --- /dev/null +++ b/apps/headless/Tests/Fixtures/file-upload.html @@ -0,0 +1,49 @@ + + + + + File upload fixture + + +
+

File upload fixture

+ + + + + + + + + + + + + + + waiting +
+ + + diff --git a/apps/headless/Tests/HeadlessMCPTests/main.swift b/apps/headless/Tests/HeadlessMCPTests/main.swift index 28020db..bf0a6b9 100644 --- a/apps/headless/Tests/HeadlessMCPTests/main.swift +++ b/apps/headless/Tests/HeadlessMCPTests/main.swift @@ -67,6 +67,7 @@ func run() throws { #"{"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"]}}}"#, #"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["config","list"]}}}"#, + #"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["artifacts","add","/tmp/resume.txt","--name","resume.txt"]}}}"#, ] try process.run() @@ -82,7 +83,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 == 12, "expected twelve MCP responses, received \(responses.count)") + try expect(responses.count == 13, "expected thirteen 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") @@ -185,6 +186,17 @@ func run() throws { throw TestFailure(description: "config rejection text was absent") } try expect(configText.contains("browser commands only"), "config rejection guidance changed") + + let ingestCall = try object(responses[12]["result"], "artifact ingest rejection result was absent") + try expect(ingestCall["isError"] as? Bool == true, "artifact ingest was accepted over MCP") + guard let ingestContent = ingestCall["content"] as? [[String: Any]], + let ingestText = ingestContent.first?["text"] as? String else { + throw TestFailure(description: "artifact ingest rejection text was absent") + } + try expect( + ingestText.contains("artifacts list"), + "artifact ingest rejection should expose only the supported list command" + ) } do { diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index cba9edb..fe1f373 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -12,6 +12,15 @@ private struct TestFailure: Error, CustomStringConvertible { let description: String } +private let tinyPNG = Data([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0x3F, + 0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59, 0xE7, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +]) + private func expect(_ condition: @autoclosure () throws -> Bool, _ message: String) throws { guard try condition() else { throw TestFailure(description: message) } } @@ -253,6 +262,16 @@ private final class TestBrowserSession: BrowserEngineSession { } func hostClick(parameters: [String: JSONValue]) throws -> JSONValue { .object(["clicked": .bool(true)]) } func hostFill(parameters: [String: JSONValue]) throws -> JSONValue { .object(["filled": .bool(true)]) } + private(set) var lastUploadPath: String? + func hostUpload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue { + lastUploadPath = artifactURL.path + return .object([ + "uploaded": .string(parameters["target"]?.stringValue ?? "@e1"), + "artifact": .string(artifactURL.lastPathComponent), + "role": .string(parameters["role"]?.stringValue ?? "textbox"), + "name": .string(parameters["name"]?.stringValue ?? ""), + ]) + } func hostPress(parameters: [String: JSONValue]) throws -> JSONValue { .object(["pressed": .bool(true)]) } func hostScroll(parameters: [String: JSONValue]) throws -> JSONValue { .object(["scrolled": .bool(true)]) } func hostWait(parameters: [String: JSONValue]) throws -> JSONValue { .object(["waited": .bool(true)]) } @@ -1232,6 +1251,7 @@ struct ProtocolTests { (["tour", "--pace", "750"], .tour), (["capture-info"], .captureInfo), (["artifacts", "list"], .artifactList), + (["upload", "@e12", "--artifact", "resume.pdf"], .upload), (["qa", "report"], .qaReport), (["qa", "clear"], .qaClear), (["performance", "get"], .performanceGet), @@ -2106,6 +2126,15 @@ struct ProtocolTests { parameters: ["target": .string("@e1"), "value": .string(secret)] ) try expect(fill == nil, "fill values must never become replayable flow steps") + let uploadStep = flowStepIfSafe( + command: .upload, + parameters: ["target": .string("@e1"), "artifact": .string("resume.pdf")] + ) + try expect(uploadStep?.command == .upload, "upload may be replayed by artifact basename") + try expect( + uploadStep?.parameters["artifact"] == .string("resume.pdf"), + "recorded upload must keep the artifact basename" + ) for command in [CommandName.shutdown, .sessionClose, .cookiesList, .storageList] { try expect( flowStepIfSafe(command: command, parameters: [:]) == nil, @@ -2372,6 +2401,10 @@ struct ProtocolTests { ]), "capabilities should advertise every local config command" ) + try expect( + !localCommandNames.contains("artifacts.add"), + "capabilities must not advertise local-file ingest" + ) try expect( settingDefinitions == SettingsRegistry.shared.definitions.compactMap { $0.access == .userOnly ? nil : $0.document @@ -2422,7 +2455,7 @@ struct ProtocolTests { } try expect( BrowserEngineCapabilities.webkit.unsupportedCommands - == [.networkEmulate, .networkMockSet, .networkMockClear], + == [.networkEmulate, .networkMockSet, .networkMockClear, .upload], "WebKit unsupported commands should be explicit and exact" ) try expect( @@ -2443,6 +2476,21 @@ struct ProtocolTests { chromiumFeatures["inputDispatch"] == .string("trusted-cdp"), "Chromium should declare trusted CDP input" ) + try expect( + webkitFeatures["fileUpload"] == .bool(false), + "WebKit should declare file upload unsupported until a native attach path exists" + ) + try expect( + chromiumFeatures["fileUpload"] == .bool(true), + "Chromium should declare file upload via DOM.setFileInputFiles" + ) + guard case .array(let artifactFormats)? = document["artifacts"] else { + throw TestFailure(description: "artifact capability shape") + } + try expect( + Set(artifactFormats.compactMap(\.stringValue)).isSuperset(of: uploadArtifactExtensions), + "capabilities artifacts list should include upload extensions" + ) try expect( document["currentEngine"] == .string(currentBrowserEngineCapabilities.engine.rawValue), "capabilities should identify the engine for this binary" @@ -2584,7 +2632,14 @@ struct ProtocolTests { ) try expect(RecordingFormat.webm.videoCodec == "vp9", "recording metadata should report the codec, not encoder") try expect(!agentRuntimeJavaScript.contains("hints.push('select')"), "inspect must not advertise a missing select command") - try expect(!agentRuntimeJavaScript.contains("hints.push('upload')"), "inspect must not advertise a missing upload command") + try expect( + agentRuntimeJavaScript.contains("__headlessFileUpload"), + "upload hints must be gated on engine file-upload support" + ) + try expect( + agentRuntimeJavaScript.contains("hints.push('upload')"), + "inspect must advertise upload on file inputs when the engine supports it" + ) try expect(!agentRuntimeJavaScript.contains("hints.push('slide')"), "inspect must not advertise a missing slide command") } @@ -3500,6 +3555,136 @@ struct ProtocolTests { try expect(missing.error?.code == "SESSION_NOT_FOUND", "closed sessions should be removed from shared state") } + static func artifactUploadCommands() throws { + let semantic = try CLIParser().parse([ + "upload", "--role", "textbox", "--name", "Resume", "--artifact", "resume.pdf", + ]) + try expect(semantic.request?.command == .upload, "semantic upload should parse") + try expect(semantic.request?.parameters["artifact"] == .string("resume.pdf"), "upload artifact should parse") + try expect(semantic.request?.parameters["role"] == .string("textbox"), "upload role should parse") + try expect(semantic.request?.parameters["name"] == .string("Resume"), "upload name should parse") + + let targeted = try CLIParser().parse(["upload", "@e12", "--artifact", "resume.pdf"]) + try expect(targeted.request?.parameters["target"] == .string("@e12"), "upload ref should parse") + try expect(targeted.request?.parameters["artifact"] == .string("resume.pdf"), "upload artifact with ref should parse") + + try expectThrows("upload without a target should fail in the CLI") { + _ = try CLIParser().parse(["upload", "--artifact", "resume.pdf"]) + } + try expectThrows("upload without --artifact should fail in the CLI") { + _ = try CLIParser().parse(["upload", "@e12"]) + } + try expectThrows("CLI must not expose local-file ingest") { + _ = try CLIParser().parse(["artifacts", "add", "/etc/passwd", "--name", "resume.txt"]) + } + + try expectThrows("raw artifact.add protocol requests must be rejected as unknown") { + _ = try ProtocolCodec.decodeLine( + CommandRequest.self, + from: Data(#"{"id":"request-1","version":"0.5","command":"artifact.add","parameters":{"source":"/tmp/resume.pdf","name":"resume.pdf"}}"#.utf8) + ) + } + try CommandRequest( + command: .upload, + parameters: ["target": .string("@e12"), "artifact": .string("resume.pdf")] + ).validate() + for name in ["payload.exe", "page.html", "image.svg", "../escape.pdf"] { + try expectThrows("upload should reject \(name)") { + try CommandRequest( + command: .upload, + parameters: ["target": .string("@e1"), "artifact": .string(name)] + ).validate() + } + } + try expectThrows("upload without a target should fail validation") { + try CommandRequest( + command: .upload, + parameters: ["artifact": .string("resume.pdf")] + ).validate() + } + try expectThrows("unknown upload parameters should be rejected") { + try CommandRequest( + command: .upload, + parameters: ["target": .string("@e1"), "artifact": .string("resume.pdf"), "path": .string("/tmp/resume.pdf")] + ).validate() + } + + let root = "/tmp/headless-upload-artifact-\(UUID().uuidString)" + let outsideArtifact = root + "-outside.png" + defer { + try? FileManager.default.removeItem(atPath: root) + try? FileManager.default.removeItem(atPath: outsideArtifact) + } + let store = try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": root]) + let added = try store.write( + tinyPNG, requestedName: "tiny.png", extension: "png", prefix: "test" + ) + guard case .object(let addedMetadata) = added else { + throw TestFailure(description: "artifact metadata") + } + try expect(addedMetadata["name"] == .string("tiny.png"), "write should return the artifact name") + try expect(addedMetadata["kind"] == .string("png"), "write should report the file kind") + let pngMode = (try FileManager.default.attributesOfItem(atPath: root + "/tiny.png")[.posixPermissions] as? NSNumber)?.intValue + try expect(pngMode == 0o600, "stored upload artifact should be private") + + _ = try store.write( + Data("hello".utf8), requestedName: "notes.txt", extension: "txt", prefix: "test" + ) + guard case .object(let listing) = try store.list(), + case .array(let artifacts)? = listing["artifacts"] else { + throw TestFailure(description: "upload artifact listing") + } + let listedNames = artifacts.compactMap { value -> String? in + guard case .object(let object) = value else { return nil } + return object["name"]?.stringValue + } + try expect(listedNames.contains("tiny.png"), "listing should include stored png") + try expect(listedNames.contains("notes.txt"), "listing should include stored txt") + let resolvedURL = try store.urlForExistingArtifact(name: "tiny.png") + try expect( + resolvedURL.deletingLastPathComponent().standardizedFileURL.path == URL(fileURLWithPath: root).standardizedFileURL.path, + "resolved upload artifacts must stay inside the store" + ) + try expectThrows("missing stored artifact should fail") { + _ = try store.urlForExistingArtifact(name: "absent.pdf") + } + try tinyPNG.write(to: URL(fileURLWithPath: outsideArtifact)) + try FileManager.default.createSymbolicLink( + atPath: root + "/linked.png", withDestinationPath: outsideArtifact + ) + try expectThrows("symlinked upload artifacts must not escape the store") { + _ = try store.urlForExistingArtifact(name: "linked.png") + } + + let session = TestBrowserSession() + let engine = TestBrowserEngine() + let core = HostCore( + engine: engine, + artifacts: try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": root]), + defaultSession: session, + shutdownHandler: {} + ) + defer { core.stop() } + + try expect(session.agentControlEnableCount == 0, "artifact resolution must not enable page control") + let uploaded = core.handle(CommandRequest( + command: .upload, + parameters: ["target": .string("@e1"), "artifact": .string("tiny.png")] + )) + try expect(uploaded.ok, "HostCore upload should resolve a stored artifact") + try expect(session.lastUploadPath == root + "/tiny.png" || session.lastUploadPath == URL(fileURLWithPath: root + "/tiny.png").path, "engine must receive the store path, not the source path") + let encoded = String(decoding: try ProtocolCodec.encoder.encode(uploaded), as: UTF8.self) + try expect(!encoded.contains(root), "upload responses must not include the store path") + try expect(!encoded.contains("\"path\""), "upload responses must not include a filesystem path") + try expect(encoded.contains("tiny.png"), "upload responses should name the artifact") + + let missingUpload = core.handle(CommandRequest( + command: .upload, + parameters: ["target": .string("@e1"), "artifact": .string("absent.pdf")] + )) + try expect(missingUpload.error?.code == "ARTIFACT_ERROR", "missing upload artifacts should fail specifically") + } + static func main() { if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--peer-denied-client" { @@ -3580,6 +3765,7 @@ struct ProtocolTests { ("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle), ("host authentication orchestration", hostAuthenticationOrchestration), ("docs command reference matches help", docsCommandReferenceMatchesHelp), + ("artifact file upload boundaries", artifactUploadCommands), ] var failures = 0 diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index a9bd795..5f7d2ba 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -447,6 +447,102 @@ assert.equal( budgetedText.contextStats.encodedBytes, ); +const uploadControls = window.document.createElement('section'); +uploadControls.innerHTML = ` + + + +`; +window.document.body.prepend(uploadControls); +const fileInput = uploadControls.querySelector('input[type="file"]'); +fileInput.getBoundingClientRect = () => ({ + x: 20, y: 140, top: 140, left: 20, right: 220, bottom: 180, width: 200, height: 40, +}); +window.document.elementFromPoint = () => fileInput; +window.__headlessFileUpload = false; +const webkitFileSnapshot = agent.snapshot(false, false, {context: 'full', limit: 250}); +const webkitFileItem = webkitFileSnapshot.elements.find( + element => element.name === 'Resume' && element.inputType === 'file', +); +assert.equal(webkitFileItem?.inputType, 'file'); +assert.equal(webkitFileItem?.actions?.length ?? 0, 0); +assert.ok(!(webkitFileItem?.actions ?? []).includes('upload')); +window.__headlessFileUpload = true; +agent.snapshot(false, false, {context: 'actions', limit: 20}); +const fileSnapshot = agent.snapshot(false, false, {context: 'actions', task: 'upload Resume', limit: 20}); +const fileItem = fileSnapshot.elements.find(element => element.name === 'Resume'); +assert.equal(fileItem?.inputType, 'file'); +assert.equal(fileItem?.actions?.length, 1); +assert.equal(fileItem?.actions?.[0], 'upload'); +assert.equal(agent.fileInput({role: 'textbox', name: 'Resume'}), fileInput); +assert.equal(agent.fileInputMetadata(fileInput).uploaded, fileItem.ref); +assert.throws( + () => agent.fileInput({role: 'button', name: 'Not a file'}), + error => error.headlessCode === 'ELEMENT_NOT_FOUND' && /not a file input/.test(error.message), +); +assert.throws( + () => agent.fileInput({role: 'button', name: 'Runtime action'}), + error => error.headlessCode === 'ELEMENT_NOT_FOUND', +); +fileInput.disabled = true; +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'NOT_EDITABLE', +); +fileInput.disabled = false; +fileInput.style.display = 'none'; +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +fileInput.style.display = ''; +fileInput.style.visibility = 'hidden'; +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +fileInput.style.visibility = ''; +fileInput.style.opacity = '0'; +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +fileInput.style.opacity = ''; +uploadControls.style.opacity = '0'; +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +uploadControls.style.opacity = ''; +fileInput.getBoundingClientRect = () => ({ + x: -220, y: 140, top: 140, left: -220, right: -20, bottom: 180, width: 200, height: 40, +}); +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +fileInput.getBoundingClientRect = () => ({ + x: 20, y: 140, top: 140, left: 20, right: 20, bottom: 140, width: 0, height: 0, +}); +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +fileInput.getBoundingClientRect = () => ({ + x: 20, y: 140, top: 140, left: 20, right: 220, bottom: 180, width: 200, height: 40, +}); +window.document.elementFromPoint = () => uploadControls.querySelector('button'); +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_VISIBLE', +); +window.document.elementFromPoint = () => fileInput; +fileInput.remove(); +assert.throws( + () => agent.fileInput({target: fileItem.ref}), + error => error.headlessCode === 'ELEMENT_NOT_FOUND' && /detached/.test(error.message), +); + console.log(JSON.stringify({ selectedRegion: targetRegion.ref, full: full.contextStats, diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs index 9524c9a..469457c 100644 --- a/apps/headless/Tests/fixture-server.mjs +++ b/apps/headless/Tests/fixture-server.mjs @@ -11,6 +11,7 @@ const routes = new Map([ ['/large-document', 'large-document.html'], ['/auth-state', 'auth-state.html'], ['/auth-login', 'auth-login.html'], + ['/file-upload', 'file-upload.html'], ['/allowlist-exits', 'allowlist-exits.html'], ['/allowlist-exits/', 'allowlist-exits.html'], ['/allowlist-redirect', 'allowlist-redirect.html'], diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index ff41641..71f9a70 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -9,7 +9,7 @@ STEP="setup" FIXTURE_ROOT="$(mktemp -d /tmp/headless-fixture.XXXXXX)" INSTALL_ROOT="$(mktemp -d /tmp/headless-install.XXXXXX)" -mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/auth-login" "$FIXTURE_ROOT/api" "$FIXTURE_ROOT/allowlist-exits" "$FIXTURE_ROOT/allowlist-redirect" +mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/auth-login" "$FIXTURE_ROOT/file-upload" "$FIXTURE_ROOT/api" "$FIXTURE_ROOT/allowlist-exits" "$FIXTURE_ROOT/allowlist-redirect" cp /opt/headless/fixtures/dashboard.html "$FIXTURE_ROOT/designers/dashboard/index.html" cp /opt/headless/fixtures/next.html "$FIXTURE_ROOT/next/index.html" cp /opt/headless/fixtures/hostile.html "$FIXTURE_ROOT/hostile/index.html" @@ -17,6 +17,7 @@ cp /opt/headless/fixtures/large-document.html "$FIXTURE_ROOT/large-document/inde cp /opt/headless/fixtures/trusted-input.html "$FIXTURE_ROOT/trusted-input/index.html" cp /opt/headless/fixtures/auth-state.html "$FIXTURE_ROOT/auth-state/index.html" cp /opt/headless/fixtures/auth-login.html "$FIXTURE_ROOT/auth-login/index.html" +cp /opt/headless/fixtures/file-upload.html "$FIXTURE_ROOT/file-upload/index.html" cp /opt/headless/fixtures/allowlist-exits.html "$FIXTURE_ROOT/allowlist-exits/index.html" cp /opt/headless/fixtures/allowlist-redirect.html "$FIXTURE_ROOT/allowlist-redirect/index.html" cp /opt/headless/fixtures/api-diagnostic.json "$FIXTURE_ROOT/api/diagnostic" @@ -304,6 +305,63 @@ TRUSTED_INPUT="$(headless --session qa inspect --text)" echo "$TRUSTED_INPUT" | grep -q 'input:true' echo "$TRUSTED_INPUT" | grep -q 'key:Enter:true' echo "$TRUSTED_INPUT" | grep -q 'click:true' +STEP="file-upload" +printf 'resume-fixture\n' > "$HEADLESS_ARTIFACT_DIR/resume.txt" +chmod 600 "$HEADLESS_ARTIFACT_DIR/resume.txt" +test "$(cat "$HEADLESS_ARTIFACT_DIR/resume.txt")" = "resume-fixture" +test "$(stat -c %a "$HEADLESS_ARTIFACT_DIR/resume.txt")" = "600" +headless artifacts list | grep -q '"name":"resume.txt"' +if headless artifacts add /etc/passwd --name resume.txt >/dev/null 2>&1; then + echo "agent-facing local-file ingest was not rejected" >&2 + exit 1 +fi +headless --session qa visit http://127.0.0.1:41739/file-upload/ | grep -q 'File upload fixture' +UPLOAD_SNAPSHOT="$(headless --session qa inspect --interactive)" +echo "$UPLOAD_SNAPSHOT" | grep -q '"name":"Resume"' +echo "$UPLOAD_SNAPSHOT" | grep -q '"actions":\["upload"\]' +echo "$UPLOAD_SNAPSHOT" | grep -q '"inputType":"file"' +UPLOAD="$(headless --session qa upload --role textbox --name Resume --artifact resume.txt)" +echo "$UPLOAD" | grep -q '"artifact":"resume.txt"' +echo "$UPLOAD" | grep -q '"uploaded"' +! echo "$UPLOAD" | grep -q "$HEADLESS_ARTIFACT_DIR" +! echo "$UPLOAD" | grep -q "$FIXTURE_ROOT" +UPLOAD_PAGE="$(headless --session qa inspect --text)" +echo "$UPLOAD_PAGE" | grep -q 'resume.txt' +if MISSING_UPLOAD="$(headless --session qa upload --role textbox --name Resume --artifact missing.txt)"; then + echo "missing artifact upload was not rejected" >&2 + exit 1 +fi +echo "$MISSING_UPLOAD" | grep -q 'ARTIFACT_ERROR' +if BUTTON_UPLOAD="$(headless --session qa upload --role button --name 'Not a file' --artifact resume.txt)"; then + echo "upload to a non-file control was not rejected" >&2 + exit 1 +fi +echo "$BUTTON_UPLOAD" | grep -q 'ELEMENT_NOT_FOUND' +EPHEMERAL="$(headless --session qa upload --role textbox --name Ephemeral --artifact resume.txt)" +echo "$EPHEMERAL" | grep -q '"artifact":"resume.txt"' +echo "$EPHEMERAL" | grep -q '"uploaded"' +if DISABLED_UPLOAD="$(headless --session qa upload --role textbox --name 'Disabled resume' --artifact resume.txt)"; then + echo "disabled file input upload was not rejected" >&2 + exit 1 +fi +echo "$DISABLED_UPLOAD" | grep -E -q 'NOT_EDITABLE|ELEMENT_NOT_FOUND|OPERATION_FAILED' +if HIDDEN_NAME_UPLOAD="$(headless --session qa upload --role textbox --name 'Already hidden' --artifact resume.txt)"; then + echo "hidden file input upload was not rejected" >&2 + exit 1 +fi +echo "$HIDDEN_NAME_UPLOAD" | grep -E -q 'ELEMENT_NOT_FOUND|ELEMENT_NOT_VISIBLE|OPERATION_FAILED' +HIDE_SNAP="$(headless --session qa inspect --interactive --limit 50)" +HIDEABLE_REF="$(printf '%s' "$HIDE_SNAP" | grep -o '"name":"Hideable","ref":"@e[0-9]*"' | head -n1 | grep -o '@e[0-9]*')" +test -n "$HIDEABLE_REF" +headless --session qa click --role button --name 'Hide file input' | grep -q '"clicked"' +if HIDDEN_REF_UPLOAD="$(headless --session qa upload "$HIDEABLE_REF" --artifact resume.txt)"; then + echo "previously issued ref to a hidden file input was accepted" >&2 + exit 1 +fi +echo "$HIDDEN_REF_UPLOAD" | grep -E -q 'ELEMENT_NOT_VISIBLE|ELEMENT_NOT_FOUND|OPERATION_FAILED' +LEAVE="$(headless --session qa upload --role textbox --name Leave --artifact resume.txt)" +echo "$LEAVE" | grep -q '"artifact":"resume.txt"' +echo "$LEAVE" | grep -q '"uploaded"' headless --session qa visit http://127.0.0.1:41739/designers/dashboard/ | grep -q 'Designers Dashboard' if EXTERNAL_RESULT="$(headless --session qa click --role link --name 'External application')"; then echo "external application link was not blocked" >&2 diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 88e0207..5675725 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -328,6 +328,25 @@ if NETWORK_SIMULATION="$("$CLI" --session qa network emulate --latency 25)"; the fail fi echo "$NETWORK_SIMULATION" | grep -q 'UNSUPPORTED_CAPABILITY' +STEP="file-upload-unsupported" +printf 'resume-fixture\n' > "$HEADLESS_ARTIFACT_DIR/resume.txt" +chmod 600 "$HEADLESS_ARTIFACT_DIR/resume.txt" +if "$CLI" artifacts add /etc/passwd --name resume.txt >/dev/null 2>&1; then + echo "agent-facing local-file ingest was not rejected" >&2 + fail +fi +"$CLI" --session qa visit "http://127.0.0.1:$PORT/file-upload" | grep -q 'File upload fixture' +UPLOAD_SNAPSHOT="$("$CLI" --session qa inspect --interactive)" +echo "$UPLOAD_SNAPSHOT" | grep -q '"name":"Resume"' +if echo "$UPLOAD_SNAPSHOT" | grep -q '"actions":\["upload"\]'; then + echo "WebKit inspect advertised upload despite missing fileUpload support" >&2 + fail +fi +if UPLOAD="$("$CLI" --session qa upload --role textbox --name Resume --artifact resume.txt)"; then + echo "WebKit file upload was unexpectedly exposed" >&2 + fail +fi +echo "$UPLOAD" | grep -q 'UNSUPPORTED_CAPABILITY' STEP="flows-reports" "$CLI" --session qa flow start | grep -q '"recording":true' "$CLI" --session qa visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard' @@ -466,9 +485,11 @@ fi STEP="durable-authentication-profile" "$CLI" start --background | grep -q '"ready":true' +STEP="durable-authentication-login" "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" | grep -q 'Authentication State' "$CLI" inspect --text | grep -q 'Cookie state: signed-in' "$CLI" inspect --text | grep -q 'Storage state: signed-in' +STEP="durable-authentication-first-stop" PROFILE_RESTART_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$PROFILE_RESTART_PID" "$CLI" stop >/dev/null @@ -480,13 +501,16 @@ if kill -0 "$PROFILE_RESTART_PID" >/dev/null 2>&1; then echo "host did not exit during durable profile restart" >&2 fail fi +STEP="durable-authentication-persisted-state" "$CLI" start --background | grep -q '"ready":true' "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" | grep -q 'Authentication State' "$CLI" inspect --text | grep -q 'Cookie state: signed-in' "$CLI" inspect --text | grep -q 'Storage state: signed-in' +STEP="durable-authentication-logout" "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=logout" >/dev/null "$CLI" inspect --text | grep -q 'Cookie state: missing' "$CLI" inspect --text | grep -q 'Storage state: missing' +STEP="durable-authentication-second-stop" LOGOUT_RESTART_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$LOGOUT_RESTART_PID" "$CLI" stop >/dev/null @@ -498,10 +522,12 @@ if kill -0 "$LOGOUT_RESTART_PID" >/dev/null 2>&1; then echo "host did not exit while verifying durable logout" >&2 fail fi +STEP="durable-authentication-persisted-logout" "$CLI" start --background >/dev/null "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null "$CLI" inspect --text | grep -q 'Cookie state: missing' "$CLI" inspect --text | grep -q 'Storage state: missing' +STEP="durable-authentication-profile-clear" "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" >/dev/null "$CLI" profile clear | grep -q '"cleared":true' "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" | grep -q 'Authentication State' diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index e6969b6..e29c835 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -174,6 +174,7 @@ inspect [--context summary|outline|text|actions|full] [--task TEXT] [--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text] click REF | click --role ROLE [--name NAME] fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY +upload REF --artifact FILE | upload --role ROLE [--name NAME] --artifact FILE scroll [up|down|top|bottom] [--amount PX] back | reload wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS] @@ -192,11 +193,18 @@ back | reload the most recent inspection and are reissued on every inspect; region references (`@rN`) stay resolvable so you can outline first and scope later. See "Reference lifetime" in P1.md for the full contract. -- `click`, `fill`, and `press` accept either a reference or a semantic target - (`--role`/`--name`). On Linux these dispatch trusted CDP input events; +- `click`, `fill`, `upload`, and `press` accept either a reference or a semantic + target (`--role`/`--name`). On Linux these dispatch trusted CDP input events; WebKit uses synthetic input, and capabilities declare the difference. - `fill REF -- value` keeps leading dashes in the value. Flow recordings never record fill values. +- `upload` attaches a file that already lives in the private artifact store. + `--artifact` is a validated basename only, never a filesystem path. File + bytes never travel on the socket. Linux Chromium attaches through + `DOM.setFileInputFiles`; macOS WebKit returns `UNSUPPORTED_CAPABILITY`. + Downloads stay denied. Agent-facing surfaces cannot import local files; + operator-file import remains deferred until Headless has a trusted native + picker or broker that can prove explicit user approval. - `wait --timeout` and the tour duration are bounded; unbounded waits are rejected at parse time. @@ -217,6 +225,9 @@ report create [--output REPORT.json] - Screenshots and recordings become private artifacts in the per-user store, created `O_EXCL` with `0600`. They never overwrite and never leave it unless you copy them. +- `artifacts list` reports the bounded contents of the private store. `upload` + can attach an allowed existing basename from that list; it is not a local + file reader or a download manager. - `--clipboard` capture is macOS only. Linux rejects clipboard capture because VM clipboards are not a reliable boundary. - PDF screenshots and element-scoped capture follow the engine matrix reported @@ -249,7 +260,8 @@ flow start | flow stop [--output FLOW.json] | flow run FLOW.json - `visual compare` accepts only existing private PNG artifacts, not filesystem paths, and writes its difference image back into the artifact store. - Flows replay recorded commands but skip every `fill` value by design; rerun - fills explicitly when you replay. + fills explicitly when you replay. `upload` may be recorded with the artifact + basename only; replay needs that same store name. ## Where to go next diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index daab986..fa3ad97 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -104,6 +104,23 @@ headless artifacts list Artifact names cannot contain paths. Existing files are never overwritten. Artifacts are `0600` inside a `0700` per-user directory. +### File upload scope + +PR #169 implements only the safe attachment half of issue #168: Linux can +attach a validated existing artifact-store basename to a visible file input, +while macOS reports `UNSUPPORTED_CAPABILITY`. It does not implement the issue's +operator-file staging criterion. No agent-facing command can import a local +path, and a CLI prompt or `/dev/tty` check would not prove that a human approved +the file because an agent can script both. + +Issue #168 must therefore remain open or be split after #169. Its remaining +work is a trusted native picker or broker for both supported platforms that +shows the exact source file and destination name to the human, requires an +unforgeable approval action, writes a new `0600` regular artifact without +following links, and fails closed when that trusted surface is unavailable. +The E2E suites seed isolated artifact directories directly as test setup; that +is not a supported user staging workflow. + ## Scrollable-page screenshots `screenshot --every-viewport --output PREFIX` captures up to 80 numbered @@ -124,7 +141,7 @@ Inspection supports progressive disclosure across five contexts: stable region references such as `@r4`. - `text` returns deduplicated semantic snippets instead of a flat body dump. - `actions` returns visible controls and only executable protocol verbs in each - control's `actions` field (`click` or `fill`). + control's `actions` field (`click`, `fill`, or `upload`). - `full` preserves the broader page/media snapshot and explicit `--text` escape hatch. diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 8158ee4..710d3a8 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -266,7 +266,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, source: webKitQAScript, injectionTime: .atDocumentStart, forMainFrameOnly: false )) conf.userContentController.addUserScript(WKUserScript( - source: agentRuntimeJavaScript, + source: "globalThis.__headlessFileUpload = false;\n" + agentRuntimeJavaScript, injectionTime: .atDocumentStart, forMainFrameOnly: false, in: agentWorld diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index c5a4294..86cd017 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -532,6 +532,57 @@ and credentials. --- +## 23. File upload attaches existing store basenames only + +**Decision:** `upload` is a protocol command that names an existing basename +in the private artifact store and asks the engine to attach that on-disk file. +No CLI, protocol, or MCP command ingests an arbitrary local path. File bytes +never appear on the Unix socket, in protocol parameters, MCP, logs, flows, +snapshots, diagnostics, or errors. Downloads remain denied. There is no TCP +fixture server, no home-directory path on `upload`, and no arbitrary-JS verb. +`upload` targets a file input with the same grammar as `click`. + +Artifact pathname integrity relies on the private per-user store. A malicious +same-UID process can inspect or replace files there, which is the documented +same-user limitation in `SECURITY.md`; operators must isolate untrusted agents +under a separate OS account when that boundary matters. + +Linux Chromium attaches via `DOM.setFileInputFiles` using an isolated-world +objectId. Attachment success is completion: bounded `{ref, role, name}` +metadata is captured before attach, and a successful CDP response is not +followed by a second node lookup. macOS WKWebView returns +`UNSUPPORTED_CAPABILITY` until a native attach path exists that does not +evaluate page JavaScript or shuttle file bytes through JS. Capabilities +declare `fileUpload` accordingly; inspect advertises `upload` only when that +flag is true. + +**Status:** partially implemented by #169 and revised 2026-09-12 after security +review. The operator-file staging criterion in #168 remains open and must not +be closed by the attach-only implementation. + +**Rationale:** resume/import/image QA needs file inputs, and the existing store +has the confinement properties required for engine attachment. Any local-path +ingest command available to an agent, including a nominally local CLI or a +scriptable TTY confirmation, would let it copy arbitrary readable host files +into an uploadable location. That violates SECURITY.md. Putting bytes on the +wire would also exceed the frame boundary and leak contents into logs. WebKit +has no equivalent of `setFileInputFiles` without a JS hole. + +**Consequences:** agent-facing surfaces cannot import operator files. Upload +remains useful for allowed artifacts already created in the store, and test +harnesses may seed their isolated store directly. Test seeding is not a user +workflow. #168 must remain open or be split so a trusted human staging surface +is designed, implemented, and tested separately. WebKit clients must skip +upload or fail closed. Replay of `upload` requires the same artifact basename +still in the store. + +**Revisit trigger:** operator-file import requires a trusted native picker or +broker that proves explicit user approval on both supported platforms. WebKit +support separately requires a documented native attach API that does not +execute page JS or pass file bytes through the JS bridge. + +--- + ## 24. Credential broker on the unsigned local tier Numbered 24 because 22 and 23 are claimed by in-review PRs @@ -780,6 +831,7 @@ rule that durable saved-credential retrieval needs trusted per-use presence. | 20 | Omit passkeys unless Apple provisions Developer ID release | Implemented | 2026-08-12 | | 21 | Rust port of shared core, protocol layer first | In progress | 2026-08-22 | | 22 | Optional host origin allowlist on `headless start` | Implemented | 2026-09-10 | +| 23 | Upload attaches existing store basenames only; downloads denied | Partially implemented; trusted staging remains #168 | 2026-09-10 | | 24 | Credential broker on the unsigned local tier | Decided | 2026-09-10 | | 25 | Typed local settings registry; security policy stays fixed | Implemented | 2026-09-12 | | 26 | Isolated sessions own one ephemeral browser context | Implemented | 2026-09-12 | diff --git a/docs/roadmap/what-is-excellent.md b/docs/roadmap/what-is-excellent.md index b9c7a5f..af50406 100644 --- a/docs/roadmap/what-is-excellent.md +++ b/docs/roadmap/what-is-excellent.md @@ -27,9 +27,10 @@ never on CSS selectors they invented. **Preserve:** refs are observations from the host, not durable selectors; role/name is the preferred stable form; action hints (`actions: ["click"]`) -only ever advertise verbs the protocol can actually execute -(`AgentRuntime.swift:67-88` deliberately emits no `select`/`upload`/`slide` — -there is even a test grepping the JS for this, `ProtocolTests.swift:602-604`). +only ever advertise verbs the protocol can actually execute. File inputs +advertise `upload`; inspect still never advertises missing `select`/`slide` +commands. Upload attaches a private artifact-store basename; it is not a +download manager. ## 2. Progressive context pruning — the token budget as a first-class contract @@ -72,7 +73,10 @@ prompt-injected or confused agent _cannot_ violate them. - **Downloads denied.** `Browser.setDownloadBehavior deny` on Linux (`BrowserProcess.swift:196`); WKDownload cancelled on macOS (`main.swift:733-748`). Dangerous remote extensions hard-blocked (25-entry - list), archives surfaced as `caution` (`Protocol.swift:576-591`). + list), archives surfaced as `caution` (`Protocol.swift:576-591`). Upload + attaches an existing private artifact-store basename and never accepts a + local path. Agent-facing surfaces cannot ingest operator files, so the + agent cannot turn upload into a read outside the store. - **Private control plane.** `0600` socket in a `0700` per-user dir, peer-UID check (`getpeereid`/`SO_PEERCRED`), 1 MiB frame cap, strict request decoding with per-command parameter allow-lists (`Transport.swift`, @@ -86,8 +90,9 @@ prompt-injected or confused agent _cannot_ violate them. `--values` and a host started with `HEADLESS_ALLOW_SENSITIVE_DIAGNOSTICS=1`; auth/cookie/token/secret headers and URL credentials are always redacted (`Diagnostics.swift:204-235`). -- **Typed values never persisted.** Flow recording excludes `fill` — replay - files can never contain credentials (`Flows.swift:25-27`). +- **Typed values never persisted.** Flow recording excludes `fill`. `upload` + may record the artifact basename only; replay files can never contain + credentials (`Flows.swift`). - **Linux never weakens the sandbox.** No `--no-sandbox`, refuses root (`BrowserProcess.swift:161-163`); Snap Chromium rejected _before launch_ by path and shebang sniffing rather than failing mysteriously later