From a9b1c4ea6d0d5eb3d1c01985eea808842cdf740e Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:23:11 +0000 Subject: [PATCH 1/6] feat(protocol): add artifact ingest and file upload Agents can copy a local fixture into the private artifact store (`artifacts add` / `artifact.add`) and attach it to a file input (`upload`) by basename. File bytes never travel on the socket. Linux Chromium uses DOM.setFileInputFiles; WebKit returns UNSUPPORTED_CAPABILITY. Downloads stay denied. Closes #168 --- .agents/skills/headless-computer-use/SKILL.md | 4 +- .../references/commands.md | 11 +- .../references/safety.md | 8 + README.md | 4 +- apps/headless/LinuxHost/BrowserProcess.swift | 76 ++++++ apps/headless/LinuxHost/main.swift | 3 + .../Sources/HeadlessProtocol/Artifacts.swift | 76 +++++- .../Sources/HeadlessProtocol/CLI.swift | 67 ++++- .../HeadlessProtocol/Capabilities.swift | 16 +- .../Sources/HeadlessProtocol/Flows.swift | 2 +- .../Sources/HeadlessProtocol/HostCore.swift | 35 ++- .../Sources/HeadlessProtocol/Protocol.swift | 23 ++ .../Resources/AgentRuntime.js | 41 ++- apps/headless/Tests/Fixtures/file-upload.html | 26 ++ .../HeadlessProtocolTests/ProtocolTests.swift | 251 +++++++++++++++++- apps/headless/Tests/agent-runtime.test.mjs | 28 ++ apps/headless/Tests/fixture-server.mjs | 1 + apps/headless/Tests/linux-e2e.sh | 43 ++- apps/headless/Tests/macos-e2e.sh | 10 + apps/headless/docs/COMMANDS.md | 21 +- apps/headless/docs/P1.md | 2 +- docs/roadmap/architecture-decisions.md | 37 ++- docs/roadmap/what-is-excellent.md | 17 +- 23 files changed, 764 insertions(+), 38 deletions(-) create mode 100644 apps/headless/Tests/Fixtures/file-upload.html diff --git a/.agents/skills/headless-computer-use/SKILL.md b/.agents/skills/headless-computer-use/SKILL.md index d2d48d1..19d54a1 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 db46516..7182577 100644 --- a/.agents/skills/headless-computer-use/references/commands.md +++ b/.agents/skills/headless-computer-use/references/commands.md @@ -29,6 +29,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 @@ -43,7 +45,10 @@ 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`; ingest the +fixture with `artifacts add` first, then `upload --artifact BASENAME`. Upload +never takes a home-directory 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 @@ -73,11 +78,13 @@ headless --session NAME record start --fps 10 --format mp4 --quality balanced headless --session NAME record status headless --session NAME record stop --output flow.mp4 headless --session NAME capture-info +headless artifacts add /abs/or/cwd/resume.pdf --name resume.pdf 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 1724895..aa72d96 100644 --- a/.agents/skills/headless-computer-use/references/safety.md +++ b/.agents/skills/headless-computer-use/references/safety.md @@ -27,6 +27,14 @@ 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, ingest it with +`headless artifacts add SOURCE --name FILE` and then +`headless upload --role textbox --name NAME --artifact FILE` (or `upload @eN +--artifact FILE`). Upload accepts an artifact basename only — never a +home-directory 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/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index aaeae89..72591ec 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -439,6 +439,7 @@ 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, @@ -556,6 +557,28 @@ 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]) } + _ = try command("DOM.setFileInputFiles", parameters: [ + "objectId": objectId, + "files": [artifactURL.path], + ]) + var result = try evaluate( + "return globalThis.__headlessAgent.fileInputResult(args);", + input: ["args": args] + ) + if case .object(var object) = result { + object["artifact"] = .string(artifactURL.lastPathComponent) + result = .object(object) + } + return result + } + 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") @@ -1005,6 +1028,59 @@ 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 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]) diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index 0b28a66..eac1ee7 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..d093454 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,80 @@ public final class ArtifactStore: @unchecked Sendable { ]) } + /// Copies a local regular file into the store. The source is read by the + /// host process (same UID as the socket peer); file bytes never appear in + /// protocol parameters. Symlinks, directories, FIFOs, and oversized files + /// fail closed. The stored object is always a new `0600` regular file. + public func ingest(sourcePath: String, name: String) throws -> JSONValue { + do { try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) } + catch { throw ArtifactError.invalidName(name) } + let data = try readUploadSource(sourcePath) + let fileExtension = URL(fileURLWithPath: name).pathExtension.lowercased() + return try write( + data, requestedName: name, extension: fileExtension, prefix: "upload" + ) + } + + /// 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 + } + + private func readUploadSource(_ sourcePath: String) throws -> Data { + guard sourcePath.hasPrefix("/") else { + throw ArtifactError.writeFailed("Source must be an absolute path") + } + let sourceURL = URL(fileURLWithPath: sourcePath) + guard sourceURL.path.hasPrefix("/") else { + throw ArtifactError.writeFailed("Source must be an absolute path") + } + var info = stat() + guard lstat(sourceURL.path, &info) == 0 else { + throw ArtifactError.writeFailed("Source file is missing or unreadable") + } + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw ArtifactError.writeFailed("Source must be a regular file") + } + guard info.st_size >= 0, info.st_size <= off_t(ProtocolBounds.artifactUploadBytes) else { + throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") + } + let descriptor = open(sourceURL.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { + throw ArtifactError.writeFailed("Source file is missing or unreadable") + } + defer { _ = close(descriptor) } + var opened = stat() + guard fstat(descriptor, &opened) == 0, (opened.st_mode & S_IFMT) == S_IFREG else { + throw ArtifactError.writeFailed("Source must be a regular file") + } + guard opened.st_size >= 0, opened.st_size <= off_t(ProtocolBounds.artifactUploadBytes) else { + throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let data: Data + do { data = try handle.readToEnd() ?? Data() } + catch { throw ArtifactError.writeFailed("Source file is missing or unreadable") } + guard data.count <= ProtocolBounds.artifactUploadBytes else { + throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") + } + return data + } + /// 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 6c16de5..6c15180 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -152,6 +152,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: [ @@ -178,8 +180,7 @@ public struct CLIParser { case "screenshot": return try parseScreenshot(arguments, session: session, jsonOutput: jsonOutput) case "artifacts": - guard arguments == ["list"] else { throw CLIParseError.missingArgument("artifacts list") } - return remote(.artifactList, session: session, jsonOutput: jsonOutput) + return try parseArtifacts(arguments, session: session, jsonOutput: jsonOutput) case "record": return try parseRecord(arguments, session: session, jsonOutput: jsonOutput) case "qa": @@ -376,6 +377,66 @@ public struct CLIParser { return number } + private func parseArtifacts( + _ arguments: [String], session: String?, jsonOutput: Bool + ) throws -> CLIInvocation { + guard let subcommand = arguments.first else { + throw CLIParseError.missingArgument("artifacts list|add") + } + var args = Array(arguments.dropFirst()) + switch subcommand { + case "list": + try requireEmpty(args) + return remote(.artifactList, session: session, jsonOutput: jsonOutput) + case "add": + let name = try removeOption("--name", from: &args) + guard let source = args.first else { throw CLIParseError.missingArgument("SOURCE") } + guard args.count == 1 else { throw CLIParseError.invalidOption(args[1]) } + guard let name else { throw CLIParseError.missingArgument("--name") } + try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) + return remote( + .artifactAdd, + session: session, + parameters: [ + "source": .string(try absoluteSourcePath(source)), + "name": .string(name), + ], + jsonOutput: jsonOutput + ) + default: + throw CLIParseError.unknownCommand("artifacts \(subcommand)") + } + } + + 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 absoluteSourcePath(_ value: String) throws -> String { + guard !value.isEmpty else { throw CLIParseError.missingArgument("SOURCE") } + let resolved: URL + if value.hasPrefix("/") { + resolved = URL(fileURLWithPath: value) + } else { + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + resolved = URL(fileURLWithPath: value, relativeTo: cwd) + } + let path = resolved.standardizedFileURL.path + guard path.hasPrefix("/") else { throw CLIParseError.invalidOption(value) } + return path + } + private func parseTargeted( _ command: CommandName, arguments: [String], @@ -791,6 +852,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] @@ -800,6 +862,7 @@ Commands: screenshot --full-page --format pdf [--output FILE.pdf] screenshot --every-viewport|--by-section [--format png|jpg|jpeg] [--output PREFIX] artifacts list + artifacts add SOURCE --name FILE record start [--fps N] [--format mp4|mov|webm|gif] [--quality fast|balanced|high] [--output FILE] record status | record stop [--output FILE] qa report | qa clear 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 65774c2..e98ccaf 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 { @@ -226,6 +234,19 @@ public final class HostCore: @unchecked Sendable { if request.command == .artifactList { return .success(id: request.id, result: try artifacts.list()) } + if request.command == .artifactAdd { + guard let source = request.parameters["source"]?.stringValue, + let name = request.parameters["name"]?.stringValue else { + throw HostError( + code: .missingParameter, + message: "Artifact source and name are required." + ) + } + return .success( + id: request.id, + result: try artifacts.ingest(sourcePath: source, name: name) + ) + } switch request.command { case .sessionCreate: return try createSession(request) @@ -436,6 +457,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) @@ -528,7 +561,7 @@ public final class HostCore: @unchecked Sendable { ) case .flowRun: return try runFlow(request, sessionName: name) - case .ping, .shutdown, .profileClear, .sessionCreate, .sessionList, .sessionClose, .artifactList: + case .ping, .shutdown, .profileClear, .sessionCreate, .sessionList, .sessionClose, .artifactList, .artifactAdd: throw HostError(code: .invalidCommand, message: "Command is not valid in this context.") } } diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 0cf83fb..426b73e 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 @@ -73,6 +74,7 @@ public enum CommandName: String, Codable, CaseIterable, Sendable { case captureInfo = "capture.info" case screenshot case artifactList = "artifact.list" + case artifactAdd = "artifact.add" case recordStart = "record.start" case recordStatus = "record.status" case recordStop = "record.stop" @@ -271,6 +273,22 @@ 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 .artifactAdd: + try allow(["source", "name"]) + if let source = try string("source", required: true) { + guard source.hasPrefix("/") else { + throw ProtocolValidationError.invalidParameter("Source must be an absolute path") + } + } + if let name = try string("name", required: true, maximumBytes: 128) { + try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) + } case .press: try allow(["key"]) _ = try string("key", required: true, maximumBytes: 32) @@ -534,6 +552,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]) } @@ -579,6 +601,7 @@ public enum ProtocolBounds { public static let networkThroughputKbps = -1.0...1_000_000.0 public static let screenshotDimension = 16_384.0 public static let screenshotPixels = 64_000_000.0 + public static let artifactUploadBytes = 5 * 1_024 * 1_024 } public struct BoundedScreenshotRectangle: Equatable, Sendable { diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index f988887..a8adc34 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -79,21 +79,24 @@ 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 — 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') { + 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)); }; @@ -605,6 +608,26 @@ 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 fileInput = args => { + const element = target(args); + 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'); + } + return element; + }; + const fileInputResult = args => { + const element = fileInput(args); + const files = Array.from(element.files || []).map(file => String(file && file.name || '').slice(0, 128)); + return { + uploaded: refFor(element), + role: role(element), + name: name(element), + files, + }; + }; const fill = args => { const element = target(args); if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable)) { @@ -914,8 +937,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, fileInputResult, + 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..c624793 --- /dev/null +++ b/apps/headless/Tests/Fixtures/file-upload.html @@ -0,0 +1,26 @@ + + + + + File upload fixture + + +
+

File upload fixture

+ + + + waiting +
+ + + diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index c43a00e..5cd63a7 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)]) } @@ -1010,6 +1029,8 @@ struct ProtocolTests { (["tour", "--pace", "750"], .tour), (["capture-info"], .captureInfo), (["artifacts", "list"], .artifactList), + (["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"], .artifactAdd), + (["upload", "@e12", "--artifact", "resume.pdf"], .upload), (["qa", "report"], .qaReport), (["qa", "clear"], .qaClear), (["performance", "get"], .performanceGet), @@ -1874,6 +1895,22 @@ struct ProtocolTests { parameters: ["target": .string("@e1"), "value": .string(secret)] ) try expect(fill == nil, "fill values must never become replayable flow steps") + try expect( + flowStepIfSafe( + command: .artifactAdd, + parameters: ["source": .string("/tmp/resume.pdf"), "name": .string("resume.pdf")] + ) == nil, + "artifacts add must not be recorded because it carries a local path" + ) + 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, @@ -2190,7 +2227,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( @@ -2211,6 +2248,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" @@ -2352,7 +2404,7 @@ 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("hints.push('upload')"), "inspect must advertise upload on file inputs") try expect(!agentRuntimeJavaScript.contains("hints.push('slide')"), "inspect must not advertise a missing slide command") } @@ -3264,6 +3316,200 @@ struct ProtocolTests { try expect(missing.error?.code == "SESSION_NOT_FOUND", "closed sessions should be removed from shared state") } + static func artifactUploadCommands() throws { + let add = try CLIParser().parse(["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"]) + try expect(add.request?.command == .artifactAdd, "artifacts add should parse as artifact.add") + try expect(add.request?.parameters["source"] == .string("/tmp/resume.pdf"), "absolute source should be preserved") + try expect(add.request?.parameters["name"] == .string("resume.pdf"), "destination name should parse") + + let relative = try CLIParser().parse(["artifacts", "add", "resume.pdf", "--name", "resume.pdf"]) + let resolved = relative.request?.parameters["source"]?.stringValue ?? "" + try expect(resolved.hasPrefix("/"), "CLI must resolve cwd-relative sources to absolute paths") + try expect(resolved.hasSuffix("/resume.pdf") || resolved.hasSuffix("resume.pdf"), "resolved source should keep the basename") + + 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("artifacts add without --name should fail") { + _ = try CLIParser().parse(["artifacts", "add", "/tmp/resume.pdf"]) + } + try expectThrows("CLI should reject html ingest names") { + _ = try CLIParser().parse(["artifacts", "add", "/tmp/page.html", "--name", "page.html"]) + } + + try CommandRequest( + command: .artifactAdd, + parameters: ["source": .string("/tmp/resume.pdf"), "name": .string("resume.pdf")] + ).validate() + try CommandRequest( + command: .upload, + parameters: ["target": .string("@e12"), "artifact": .string("resume.pdf")] + ).validate() + try expectThrows("relative protocol source should be rejected") { + try CommandRequest( + command: .artifactAdd, + parameters: ["source": .string("resume.pdf"), "name": .string("resume.pdf")] + ).validate() + } + try expectThrows("path traversal artifact names should be rejected") { + try CommandRequest( + command: .artifactAdd, + parameters: ["source": .string("/tmp/resume.pdf"), "name": .string("../escape.pdf")] + ).validate() + } + for name in ["payload.exe", "page.html", "image.svg"] { + try expectThrows("ingest should reject \(name)") { + try CommandRequest( + command: .artifactAdd, + parameters: ["source": .string("/tmp/file"), "name": .string(name)] + ).validate() + } + 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 sourceDir = root + "-src" + defer { + try? FileManager.default.removeItem(atPath: root) + try? FileManager.default.removeItem(atPath: sourceDir) + } + try FileManager.default.createDirectory(atPath: sourceDir, withIntermediateDirectories: true) + let store = try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": root]) + let pngPath = sourceDir + "/tiny.png" + try tinyPNG.write(to: URL(fileURLWithPath: pngPath)) + let added = try store.ingest(sourcePath: pngPath, name: "tiny.png") + guard case .object(let addedMetadata) = added else { + throw TestFailure(description: "ingest metadata") + } + try expect(addedMetadata["name"] == .string("tiny.png"), "ingest should return the destination name") + try expect(addedMetadata["kind"] == .string("png"), "ingest should report the file kind") + try expect( + try Data(contentsOf: URL(fileURLWithPath: root + "/tiny.png")) == tinyPNG, + "ingest should copy source bytes into a regular store file" + ) + let pngMode = (try FileManager.default.attributesOfItem(atPath: root + "/tiny.png")[.posixPermissions] as? NSNumber)?.intValue + try expect(pngMode == 0o600, "ingested artifact should be private") + + let txtPath = sourceDir + "/notes.txt" + try Data("hello".utf8).write(to: URL(fileURLWithPath: txtPath)) + _ = try store.ingest(sourcePath: txtPath, name: "notes.txt") + 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 ingested png") + try expect(listedNames.contains("notes.txt"), "listing should include ingested txt") + + try expectThrows("ingest overwrite should fail closed") { + _ = try store.ingest(sourcePath: pngPath, name: "tiny.png") + } + let linkPath = sourceDir + "/link.png" + try FileManager.default.createSymbolicLink(atPath: linkPath, withDestinationPath: pngPath) + try expectThrows("symlink sources should be rejected") { + _ = try store.ingest(sourcePath: linkPath, name: "from-link.png") + } + try expectThrows("directory sources should be rejected") { + _ = try store.ingest(sourcePath: sourceDir, name: "folder.png") + } + let hugePath = sourceDir + "/huge.txt" + try Data(count: ProtocolBounds.artifactUploadBytes + 1).write(to: URL(fileURLWithPath: hugePath)) + try expectThrows("oversized ingest should fail closed") { + _ = try store.ingest(sourcePath: hugePath, name: "huge.txt") + } + try expectThrows("missing source should fail closed") { + _ = try store.ingest(sourcePath: sourceDir + "/missing.txt", name: "missing.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") + } + + 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() } + + let hostAdd = core.handle(CommandRequest( + command: .artifactAdd, + parameters: ["source": .string(pngPath), "name": .string("host.png")] + )) + try expect(hostAdd.ok, "HostCore artifact.add should succeed without a session") + try expect(session.agentControlEnableCount == 0, "artifact.add must not enable page control") + guard case .object(let hostAddResult) = hostAdd.result else { + throw TestFailure(description: "HostCore artifact.add result") + } + try expect(hostAddResult["name"] == .string("host.png"), "HostCore ingest should return store metadata") + + let uploaded = core.handle(CommandRequest( + command: .upload, + parameters: ["target": .string("@e1"), "artifact": .string("host.png")] + )) + try expect(uploaded.ok, "HostCore upload should resolve a stored artifact") + try expect(session.lastUploadPath == root + "/host.png" || session.lastUploadPath == URL(fileURLWithPath: root + "/host.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(pngPath), "upload responses must not include the source path") + try expect(!encoded.contains("\"path\""), "upload responses must not include a filesystem path") + try expect(encoded.contains("host.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") + + let overwrite = core.handle(CommandRequest( + command: .artifactAdd, + parameters: ["source": .string(pngPath), "name": .string("host.png")] + )) + try expect(!overwrite.ok, "HostCore ingest overwrite should fail") + try expect(overwrite.error?.code == "ARTIFACT_ERROR", "overwrite should surface as an artifact error") + } + static func main() { if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--peer-denied-client" { @@ -3343,6 +3589,7 @@ struct ProtocolTests { ("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle), ("host authentication orchestration", hostAuthenticationOrchestration), ("docs command reference matches help", docsCommandReferenceMatchesHelp), + ("artifact ingest and file upload", artifactUploadCommands), ] var failures = 0 diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index b1df2d9..617a236 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -397,6 +397,34 @@ 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, +}); +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.fileInputResult({role: 'textbox', name: 'Resume'}).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', +); + 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 67f9263..982ff9b 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'], ]); const server = createServer(async (request, response) => { diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 352c200..93bd46b 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" +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" 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/api-diagnostic.json "$FIXTURE_ROOT/api/diagnostic" busybox httpd -f -p 127.0.0.1:41739 -h "$FIXTURE_ROOT" & FIXTURE_PID=$! @@ -302,6 +303,46 @@ 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' +printf 'resume-fixture\n' > "$FIXTURE_ROOT/resume.txt" +ADD="$(headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.txt)" +echo "$ADD" | grep -q '"name":"resume.txt"' +echo "$ADD" | grep -q '"kind":"txt"' +test "$(cat "$HEADLESS_ARTIFACT_DIR/resume.txt")" = "resume-fixture" +test "$(stat -c %a "$HEADLESS_ARTIFACT_DIR/resume.txt")" = "600" +printf 'cover-letter\n' > "$FIXTURE_ROOT/cover.txt" +RELATIVE_ADD="$(cd "$FIXTURE_ROOT" && headless artifacts add ./cover.txt --name cover.txt)" +echo "$RELATIVE_ADD" | grep -q '"name":"cover.txt"' +headless artifacts list | grep -q '"name":"resume.txt"' +if headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.txt >/dev/null 2>&1; then + echo "artifact ingest overwrite was not rejected" >&2 + exit 1 +fi +if headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.html >/dev/null 2>&1; then + echo "html artifact 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' 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 9624d39..710ba6f 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -328,6 +328,16 @@ if NETWORK_SIMULATION="$("$CLI" --session qa network emulate --latency 25)"; the fail fi echo "$NETWORK_SIMULATION" | grep -q 'UNSUPPORTED_CAPABILITY' +STEP="file-upload-unsupported" +UPLOAD_SOURCE="$(mktemp "${TMPDIR:-/tmp}/headless-upload-source.XXXXXX")" +printf 'resume-fixture\n' > "$UPLOAD_SOURCE" +"$CLI" artifacts add "$UPLOAD_SOURCE" --name resume.txt | grep -q '"name":"resume.txt"' +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' +rm -f "$UPLOAD_SOURCE" 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' diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index 0fc073c..a2ca197 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -169,6 +169,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] @@ -187,11 +188,16 @@ 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. Ingest the fixture first with `artifacts add`. - `wait --timeout` and the tour duration are bounded; unbounded waits are rejected at parse time. @@ -203,6 +209,7 @@ screenshot [REF | --role ROLE --name NAME | --full-page] [--format png|jpg|jpeg] screenshot --full-page --format pdf [--output FILE.pdf] screenshot --every-viewport|--by-section [--format png|jpg|jpeg] [--output PREFIX] artifacts list +artifacts add SOURCE --name FILE record start [--fps N] [--format mp4|mov|webm|gif] [--quality fast|balanced|high] [--output FILE] record status | record stop [--output FILE] qa report | qa clear @@ -212,6 +219,12 @@ 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 add` copies a local regular file (absolute or cwd-relative; 5 MiB + cap; `pdf`/`png`/`jpg`/`jpeg`/`gif`/`webp`/`txt`/`csv`/`json` only) into that + store as a new `0600` name. It is a protocol command so MCP can ingest too. + HTML, SVG, executables, and archives are rejected. The stored object is + bytes, not a symlink. `upload` then names that basename; it is not 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 @@ -244,7 +257,9 @@ 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. `artifacts add` is never + recorded because it carries a local path. ## Where to go next diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index daab986..7fd66a0 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -124,7 +124,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/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 1fa0793..20d9188 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -487,6 +487,40 @@ no TCP listener, fail closed, bounded everything. --- +## 23. File upload is artifact-store ingest plus engine attach + +**Decision:** agents attach files that already live in the private artifact +store. `artifact.add` copies a local regular file into the store (validated +basename, `O_EXCL`, `0600`, 5 MiB, allow-listed extensions). `upload` targets +a file input with the same grammar as `click` and asks the engine to attach +that on-disk artifact. 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. + +Linux Chromium attaches via `DOM.setFileInputFiles` using an isolated-world +objectId. 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. + +**Status:** decided 2026-09-10 (owner-approved product contract for #168). + +**Rationale:** resume/import/image QA needs file inputs; the existing store +already has the safety properties we need. Putting bytes on the wire would +blow the 1 MiB frame and leak file contents into logs. WebKit has no +equivalent of `setFileInputFiles` without a JS hole. + +**Consequences:** MCP can ingest because `artifact.add` is a protocol command +(MCP rejects local-only CLI). WebKit clients must skip upload or fail closed. +Replay of `upload` requires the same artifact basename still in the store. +`artifacts add` is not flow-recorded because it carries a local filesystem +path. + +**Revisit trigger:** a documented WKWebView/native attach API that does not +execute page JS and does not 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 @@ -734,9 +768,10 @@ rule that durable saved-credential retrieval needs trusted per-use presence. | 19 | Keep macOS agent startup behind the current app | Implemented | 2026-08-12 | | 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 | +| 23 | Artifact-store ingest + engine file attach; downloads denied | Decided | 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 | | 27 | Interactive authentication keeps consent in trusted host | Implemented | 2026-09-12 | -New decisions append here with the same format. 22 and 23 are claimed by open PRs #170 and #169. +New decisions append here with the same format. diff --git a/docs/roadmap/what-is-excellent.md b/docs/roadmap/what-is-excellent.md index 6af4ce7..67fa810 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 @@ -69,7 +70,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 is + the inverse: a user-supplied fixture is ingested into the private artifact + store (`artifact.add`) and attached by basename (`upload`). It is not a + download manager. - **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`, @@ -83,8 +87,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` and + `artifact.add` (local paths). `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 From 848aaf01c894e8bfa5cffbea3ab59f91664b8048 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:44:24 +0000 Subject: [PATCH 2/6] fix(protocol): keep artifact ingest off the agent socket Ingest is a local CLI/TTY operator path, never a protocol or MCP command. Upload still attaches store basenames only. Bound the ingest reader, treat successful CDP attachment as completion, revalidate file inputs before attach, and suppress WebKit upload hints. --- SECURITY.md | 2 +- apps/headless/Host/AgentBridge.swift | 5 +- apps/headless/LinuxHost/BrowserProcess.swift | 24 ++--- apps/headless/MCP/main.swift | 8 ++ apps/headless/Sources/HeadlessCLI/main.swift | 8 ++ .../Sources/HeadlessProtocol/Artifacts.swift | 46 +++++---- .../Sources/HeadlessProtocol/CLI.swift | 10 +- .../HeadlessProtocol/Capabilities.swift | 1 + .../Sources/HeadlessProtocol/HostCore.swift | 15 +-- .../Sources/HeadlessProtocol/Protocol.swift | 11 --- .../Resources/AgentRuntime.js | 23 ++++- apps/headless/Tests/Fixtures/file-upload.html | 23 +++++ .../Tests/HeadlessMCPTests/main.swift | 14 ++- .../HeadlessProtocolTests/ProtocolTests.swift | 94 +++++++------------ apps/headless/Tests/agent-runtime.test.mjs | 35 +++++++ apps/headless/Tests/linux-e2e.sh | 26 +++++ apps/headless/Tests/macos-e2e.sh | 7 ++ apps/headless/docs/COMMANDS.md | 12 ++- apps/headless/main.swift | 2 +- docs/roadmap/architecture-decisions.md | 58 +++++++----- docs/roadmap/what-is-excellent.md | 12 ++- 21 files changed, 279 insertions(+), 157 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4419d95..077dbea 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. `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. Ingest of operator files is a local CLI path, never a protocol or MCP command. | | **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 72591ec..ddcb227 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() @@ -442,7 +444,7 @@ final class LinuxBrowserSession: @unchecked Sendable { _ = try command("DOM.enable") _ = try command("Log.enable") _ = try command("Page.addScriptToEvaluateOnNewDocument", parameters: [ - "source": agentRuntimeJavaScript, + "source": linuxAgentRuntimeJavaScript, "worldName": "HeadlessAgent", "runImmediately": true, ]) @@ -559,6 +561,13 @@ final class LinuxBrowserSession: @unchecked Sendable { func upload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue { let args = try browserTargetArguments(parameters) + let prepared = try evaluate( + "return globalThis.__headlessAgent.fileInputPrepare(args);", + input: ["args": args] + ) + guard case .object(var metadata) = prepared else { + throw CDPError.invalidResponse("file input metadata") + } let objectId = try evaluateNode( "return globalThis.__headlessAgent.fileInput(args);", input: ["args": args] @@ -568,15 +577,8 @@ final class LinuxBrowserSession: @unchecked Sendable { "objectId": objectId, "files": [artifactURL.path], ]) - var result = try evaluate( - "return globalThis.__headlessAgent.fileInputResult(args);", - input: ["args": args] - ) - if case .object(var object) = result { - object["artifact"] = .string(artifactURL.lastPathComponent) - result = .object(object) - } - return result + metadata["artifact"] = .string(artifactURL.lastPathComponent) + return .object(metadata) } func fill(parameters: [String: JSONValue]) throws -> JSONValue { @@ -1167,7 +1169,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/MCP/main.swift b/apps/headless/MCP/main.swift index aa3004e..35e89e3 100644 --- a/apps/headless/MCP/main.swift +++ b/apps/headless/MCP/main.swift @@ -76,6 +76,14 @@ while let line = readLine() { ) continue } + if case .artifactsAdd = invocation.local { + toolResult( + id: id, + text: "Artifact ingest requires a local operator and is 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/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index 489f192..0b7995b 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -225,6 +225,8 @@ do { } case .credentials(let command): try CredentialBrokerLauncher().run(command) + case .artifactsAdd(let source, let name): + printJSON(try ArtifactStore().ingest(sourcePath: source, name: name)) } } else if let request = invocation.request { let launcher = HostLauncher() @@ -290,6 +292,12 @@ do { ) try? printResponse(response) exit(69) +} catch let error as ArtifactError { + let response = CommandResponse.failure( + id: "unknown", code: "ARTIFACT_ERROR", message: error.description + ) + try? printResponse(response) + exit(69) } catch { fputs("headless: \(error)\n", stderr) exit(70) diff --git a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift index d093454..f96f01a 100644 --- a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift +++ b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift @@ -222,10 +222,11 @@ public final class ArtifactStore: @unchecked Sendable { ]) } - /// Copies a local regular file into the store. The source is read by the - /// host process (same UID as the socket peer); file bytes never appear in - /// protocol parameters. Symlinks, directories, FIFOs, and oversized files - /// fail closed. The stored object is always a new `0600` regular file. + /// Copies a local regular file into the store. Called only from the local + /// CLI process (same UID as the operator). File bytes never appear on the + /// Unix socket, and HostCore cannot reach this path. Symlinks, directories, + /// FIFOs, and oversized files fail closed. The stored object is always a + /// new `0600` regular file. public func ingest(sourcePath: String, name: String) throws -> JSONValue { do { try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) } catch { throw ArtifactError.invalidName(name) } @@ -271,9 +272,6 @@ public final class ArtifactStore: @unchecked Sendable { guard (info.st_mode & S_IFMT) == S_IFREG else { throw ArtifactError.writeFailed("Source must be a regular file") } - guard info.st_size >= 0, info.st_size <= off_t(ProtocolBounds.artifactUploadBytes) else { - throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") - } let descriptor = open(sourceURL.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) guard descriptor >= 0 else { throw ArtifactError.writeFailed("Source file is missing or unreadable") @@ -283,15 +281,31 @@ public final class ArtifactStore: @unchecked Sendable { guard fstat(descriptor, &opened) == 0, (opened.st_mode & S_IFMT) == S_IFREG else { throw ArtifactError.writeFailed("Source must be a regular file") } - guard opened.st_size >= 0, opened.st_size <= off_t(ProtocolBounds.artifactUploadBytes) else { - throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") - } - let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) - let data: Data - do { data = try handle.readToEnd() ?? Data() } - catch { throw ArtifactError.writeFailed("Source file is missing or unreadable") } - guard data.count <= ProtocolBounds.artifactUploadBytes else { - throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") + // st_size is not the read bound. A growing or lying regular file is + // stopped at the limit without slurping the rest of the file. + return try readBounded( + descriptor: descriptor, maximumBytes: ProtocolBounds.artifactUploadBytes + ) + } + + private func readBounded(descriptor: Int32, maximumBytes: Int, chunkBytes: Int = 64 * 1_024) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: max(1, chunkBytes)) + while true { + #if canImport(Darwin) + let count = Darwin.read(descriptor, &buffer, buffer.count) + #else + let count = Glibc.read(descriptor, &buffer, buffer.count) + #endif + if count < 0 && errno == EINTR { continue } + guard count >= 0 else { + throw ArtifactError.writeFailed("Source file is missing or unreadable") + } + if count == 0 { break } + if data.count + count > maximumBytes { + throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") + } + data.append(buffer, count: count) } return data } diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index 6c15180..6887ced 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -21,6 +21,7 @@ public enum LocalCommand: Equatable, Sendable { case start(presentation: AgentStartupPresentation?) case config(ConfigCLICommand) case credentials(CredentialCLICommand) + case artifactsAdd(source: String, name: String) } public struct CLIInvocation: Equatable, Sendable { @@ -394,13 +395,8 @@ public struct CLIParser { guard args.count == 1 else { throw CLIParseError.invalidOption(args[1]) } guard let name else { throw CLIParseError.missingArgument("--name") } try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) - return remote( - .artifactAdd, - session: session, - parameters: [ - "source": .string(try absoluteSourcePath(source)), - "name": .string(name), - ], + return CLIInvocation( + local: .artifactsAdd(source: try absoluteSourcePath(source), name: name), jsonOutput: jsonOutput ) default: diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index ab0d9df..1472fca 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -205,6 +205,7 @@ public let capabilitiesDocument: JSONValue = { ]), "screenshotSeries": stringArray(["viewport", "section"]), "localCommands": stringArray([ + "artifacts.add", "config.describe", "config.get", "config.list", "config.reset", "config.set", "credentials.add", "credentials.list", "credentials.remove", "credentials.rename", ]), diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index e98ccaf..4adf23d 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -234,19 +234,6 @@ public final class HostCore: @unchecked Sendable { if request.command == .artifactList { return .success(id: request.id, result: try artifacts.list()) } - if request.command == .artifactAdd { - guard let source = request.parameters["source"]?.stringValue, - let name = request.parameters["name"]?.stringValue else { - throw HostError( - code: .missingParameter, - message: "Artifact source and name are required." - ) - } - return .success( - id: request.id, - result: try artifacts.ingest(sourcePath: source, name: name) - ) - } switch request.command { case .sessionCreate: return try createSession(request) @@ -561,7 +548,7 @@ public final class HostCore: @unchecked Sendable { ) case .flowRun: return try runFlow(request, sessionName: name) - case .ping, .shutdown, .profileClear, .sessionCreate, .sessionList, .sessionClose, .artifactList, .artifactAdd: + case .ping, .shutdown, .profileClear, .sessionCreate, .sessionList, .sessionClose, .artifactList: throw HostError(code: .invalidCommand, message: "Command is not valid in this context.") } } diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 426b73e..999971d 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -74,7 +74,6 @@ public enum CommandName: String, Codable, CaseIterable, Sendable { case captureInfo = "capture.info" case screenshot case artifactList = "artifact.list" - case artifactAdd = "artifact.add" case recordStart = "record.start" case recordStatus = "record.status" case recordStop = "record.stop" @@ -279,16 +278,6 @@ public struct CommandRequest: Codable, Equatable, Sendable { if let artifact = try string("artifact", required: true, maximumBytes: 128) { try validateArtifactName(artifact, expectedExtensions: uploadArtifactExtensions) } - case .artifactAdd: - try allow(["source", "name"]) - if let source = try string("source", required: true) { - guard source.hasPrefix("/") else { - throw ProtocolValidationError.invalidParameter("Source must be an absolute path") - } - } - if let name = try string("name", required: true, maximumBytes: 128) { - try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) - } case .press: try allow(["key"]) _ = try string("key", required: true, maximumBytes: 32) diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index a8adc34..3bc5f86 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -82,11 +82,12 @@ if (!globalThis.__headlessAgent) { // 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/slide commands. File inputs advertise - // upload only — never fill or click as the primary verb. + // 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') { - hints.push('upload'); + 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'); @@ -616,8 +617,24 @@ if (!globalThis.__headlessAgent) { 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'); + } + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) { + fail('ELEMENT_NOT_VISIBLE', 'ELEMENT_NOT_VISIBLE: file input is not visible'); + } return element; }; + const fileInputPrepare = args => { + const element = fileInput(args); + return { + uploaded: refFor(element), + role: role(element), + name: name(element), + }; + }; const fileInputResult = args => { const element = fileInput(args); const files = Array.from(element.files || []).map(file => String(file && file.name || '').slice(0, 128)); @@ -937,7 +954,7 @@ if (!globalThis.__headlessAgent) { return {count: document.getAnimations().length, animations: all, truncated: document.getAnimations().length > all.length}; }; return { - snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputResult, + snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputPrepare, fileInputResult, 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 index c624793..ec62842 100644 --- a/apps/headless/Tests/Fixtures/file-upload.html +++ b/apps/headless/Tests/Fixtures/file-upload.html @@ -9,11 +9,25 @@

File upload fixture

+ + + + + + + + + + + waiting diff --git a/apps/headless/Tests/HeadlessMCPTests/main.swift b/apps/headless/Tests/HeadlessMCPTests/main.swift index 28020db..ccfea2b 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("local operator") && ingestText.contains("unavailable over MCP"), + "artifact ingest rejection should require a local operator" + ) } do { diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 5cd63a7..08dcdb5 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1029,7 +1029,6 @@ struct ProtocolTests { (["tour", "--pace", "750"], .tour), (["capture-info"], .captureInfo), (["artifacts", "list"], .artifactList), - (["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"], .artifactAdd), (["upload", "@e12", "--artifact", "resume.pdf"], .upload), (["qa", "report"], .qaReport), (["qa", "clear"], .qaClear), @@ -1064,6 +1063,9 @@ struct ProtocolTests { (["credentials", "list", "--origin", "https://example.com"], .credentials(.list( origin: try CredentialOrigin(rawValue: "https://example.com") ))), + (["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"], .artifactsAdd( + source: "/tmp/resume.pdf", name: "resume.pdf" + )), (["help"], .help), (["--help"], .help), (["version"], .version), @@ -1895,13 +1897,6 @@ struct ProtocolTests { parameters: ["target": .string("@e1"), "value": .string(secret)] ) try expect(fill == nil, "fill values must never become replayable flow steps") - try expect( - flowStepIfSafe( - command: .artifactAdd, - parameters: ["source": .string("/tmp/resume.pdf"), "name": .string("resume.pdf")] - ) == nil, - "artifacts add must not be recorded because it carries a local path" - ) let uploadStep = flowStepIfSafe( command: .upload, parameters: ["target": .string("@e1"), "artifact": .string("resume.pdf")] @@ -2173,9 +2168,10 @@ struct ProtocolTests { let localCommandNames = Set(localCommands.compactMap(\.stringValue)) try expect( localCommandNames.isSuperset(of: [ + "artifacts.add", "config.describe", "config.get", "config.list", "config.reset", "config.set", ]), - "capabilities should advertise every local config command" + "capabilities should advertise every local config and ingest command" ) try expect( settingDefinitions == SettingsRegistry.shared.definitions.compactMap { @@ -2404,7 +2400,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 advertise upload on file inputs") + 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") } @@ -3318,14 +3321,20 @@ struct ProtocolTests { static func artifactUploadCommands() throws { let add = try CLIParser().parse(["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"]) - try expect(add.request?.command == .artifactAdd, "artifacts add should parse as artifact.add") - try expect(add.request?.parameters["source"] == .string("/tmp/resume.pdf"), "absolute source should be preserved") - try expect(add.request?.parameters["name"] == .string("resume.pdf"), "destination name should parse") + try expect(add.request == nil, "artifacts add must not become a socket command") + try expect( + add.local == .artifactsAdd(source: "/tmp/resume.pdf", name: "resume.pdf"), + "artifacts add should stay a local CLI ingest" + ) let relative = try CLIParser().parse(["artifacts", "add", "resume.pdf", "--name", "resume.pdf"]) - let resolved = relative.request?.parameters["source"]?.stringValue ?? "" + guard case .artifactsAdd(let resolved, let relativeName) = relative.local else { + throw TestFailure(description: "relative artifacts add should stay local") + } + try expect(relative.request == nil, "relative artifacts add must not become a socket command") try expect(resolved.hasPrefix("/"), "CLI must resolve cwd-relative sources to absolute paths") try expect(resolved.hasSuffix("/resume.pdf") || resolved.hasSuffix("resume.pdf"), "resolved source should keep the basename") + try expect(relativeName == "resume.pdf", "destination name should parse") let semantic = try CLIParser().parse([ "upload", "--role", "textbox", "--name", "Resume", "--artifact", "resume.pdf", @@ -3352,33 +3361,17 @@ struct ProtocolTests { _ = try CLIParser().parse(["artifacts", "add", "/tmp/page.html", "--name", "page.html"]) } - try CommandRequest( - command: .artifactAdd, - parameters: ["source": .string("/tmp/resume.pdf"), "name": .string("resume.pdf")] - ).validate() + 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() - try expectThrows("relative protocol source should be rejected") { - try CommandRequest( - command: .artifactAdd, - parameters: ["source": .string("resume.pdf"), "name": .string("resume.pdf")] - ).validate() - } - try expectThrows("path traversal artifact names should be rejected") { - try CommandRequest( - command: .artifactAdd, - parameters: ["source": .string("/tmp/resume.pdf"), "name": .string("../escape.pdf")] - ).validate() - } - for name in ["payload.exe", "page.html", "image.svg"] { - try expectThrows("ingest should reject \(name)") { - try CommandRequest( - command: .artifactAdd, - parameters: ["source": .string("/tmp/file"), "name": .string(name)] - ).validate() - } + for name in ["payload.exe", "page.html", "image.svg", "../escape.pdf"] { try expectThrows("upload should reject \(name)") { try CommandRequest( command: .upload, @@ -3449,7 +3442,7 @@ struct ProtocolTests { } let hugePath = sourceDir + "/huge.txt" try Data(count: ProtocolBounds.artifactUploadBytes + 1).write(to: URL(fileURLWithPath: hugePath)) - try expectThrows("oversized ingest should fail closed") { + try expectThrows("oversized ingest should fail from the chunked reader, not a trusted st_size") { _ = try store.ingest(sourcePath: hugePath, name: "huge.txt") } try expectThrows("missing source should fail closed") { @@ -3474,40 +3467,23 @@ struct ProtocolTests { ) defer { core.stop() } - let hostAdd = core.handle(CommandRequest( - command: .artifactAdd, - parameters: ["source": .string(pngPath), "name": .string("host.png")] - )) - try expect(hostAdd.ok, "HostCore artifact.add should succeed without a session") - try expect(session.agentControlEnableCount == 0, "artifact.add must not enable page control") - guard case .object(let hostAddResult) = hostAdd.result else { - throw TestFailure(description: "HostCore artifact.add result") - } - try expect(hostAddResult["name"] == .string("host.png"), "HostCore ingest should return store metadata") - + try expect(session.agentControlEnableCount == 0, "local ingest must not enable page control") let uploaded = core.handle(CommandRequest( command: .upload, - parameters: ["target": .string("@e1"), "artifact": .string("host.png")] + parameters: ["target": .string("@e1"), "artifact": .string("tiny.png")] )) try expect(uploaded.ok, "HostCore upload should resolve a stored artifact") - try expect(session.lastUploadPath == root + "/host.png" || session.lastUploadPath == URL(fileURLWithPath: root + "/host.png").path, "engine must receive the store path, not the source path") + 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(pngPath), "upload responses must not include the source path") try expect(!encoded.contains("\"path\""), "upload responses must not include a filesystem path") - try expect(encoded.contains("host.png"), "upload responses should name the artifact") + 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") - - let overwrite = core.handle(CommandRequest( - command: .artifactAdd, - parameters: ["source": .string(pngPath), "name": .string("host.png")] - )) - try expect(!overwrite.ok, "HostCore ingest overwrite should fail") - try expect(overwrite.error?.code == "ARTIFACT_ERROR", "overwrite should surface as an artifact error") } static func main() { diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index 617a236..40d7aee 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -408,6 +408,15 @@ 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.__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'); @@ -415,6 +424,7 @@ 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.fileInputPrepare({role: 'textbox', name: 'Resume'}).uploaded, fileItem.ref); assert.equal(agent.fileInputResult({role: 'textbox', name: 'Resume'}).uploaded, fileItem.ref); assert.throws( () => agent.fileInput({role: 'button', name: 'Not a file'}), @@ -424,6 +434,31 @@ 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.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', +); console.log(JSON.stringify({ selectedRegion: targetRegion.ref, diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 93bd46b..00607ee 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -303,6 +303,7 @@ 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' > "$FIXTURE_ROOT/resume.txt" ADD="$(headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.txt)" echo "$ADD" | grep -q '"name":"resume.txt"' @@ -343,6 +344,31 @@ if BUTTON_UPLOAD="$(headless --session qa upload --role button --name 'Not a fil 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 710ba6f..234c6bc 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -332,6 +332,13 @@ STEP="file-upload-unsupported" UPLOAD_SOURCE="$(mktemp "${TMPDIR:-/tmp}/headless-upload-source.XXXXXX")" printf 'resume-fixture\n' > "$UPLOAD_SOURCE" "$CLI" artifacts add "$UPLOAD_SOURCE" --name resume.txt | grep -q '"name":"resume.txt"' +"$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 diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index a2ca197..6b04e28 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -197,7 +197,8 @@ back | reload `--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. Ingest the fixture first with `artifacts add`. + Downloads stay denied. Ingest the fixture first with the local CLI + `artifacts add`; that path is not a protocol or MCP command. - `wait --timeout` and the tour duration are bounded; unbounded waits are rejected at parse time. @@ -221,10 +222,11 @@ report create [--output REPORT.json] you copy them. - `artifacts add` copies a local regular file (absolute or cwd-relative; 5 MiB cap; `pdf`/`png`/`jpg`/`jpeg`/`gif`/`webp`/`txt`/`csv`/`json` only) into that - store as a new `0600` name. It is a protocol command so MCP can ingest too. + store as a new `0600` name. It runs in the CLI process as a local operator + path, like `credentials`; it is not a socket command and MCP rejects it. HTML, SVG, executables, and archives are rejected. The stored object is - bytes, not a symlink. `upload` then names that basename; it is not a - download manager. + bytes, not a symlink. `artifacts list` remains a protocol command. `upload` + then names that basename; it is not 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 @@ -259,7 +261,7 @@ flow start | flow stop [--output FLOW.json] | flow run FLOW.json - Flows replay recorded commands but skip every `fill` value by design; rerun fills explicitly when you replay. `upload` may be recorded with the artifact basename only; replay needs that same store name. `artifacts add` is never - recorded because it carries a local path. + recorded because it is a local CLI path, not a protocol command. ## Where to go next diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 902b04a..be2705a 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 20d9188..fea1435 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -487,34 +487,48 @@ no TCP listener, fail closed, bounded everything. --- -## 23. File upload is artifact-store ingest plus engine attach - -**Decision:** agents attach files that already live in the private artifact -store. `artifact.add` copies a local regular file into the store (validated -basename, `O_EXCL`, `0600`, 5 MiB, allow-listed extensions). `upload` targets -a file input with the same grammar as `click` and asks the engine to attach -that on-disk artifact. 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. +## 23. File upload is local ingest plus engine attach of store basenames + +**Decision:** ingest of a local file into the private artifact store is a +local CLI/TTY operator path (`headless artifacts add`), never a protocol +command and never available over MCP. The agent cannot read outside the +artifact store. `upload` is a protocol command that names a basename already +in the store and asks the engine to attach that on-disk file. 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. + +`artifacts add` copies a local regular file into the store in the CLI process +(same UID as the operator): validated basename, `O_EXCL`, `0600`, 5 MiB +chunked read that does not trust `st_size` as the read bound, allow-listed +extensions. `artifacts list` remains a protocol command. `upload` targets a +file input with the same grammar as `click`. Linux Chromium attaches via `DOM.setFileInputFiles` using an isolated-world -objectId. 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. - -**Status:** decided 2026-09-10 (owner-approved product contract for #168). +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:** revised 2026-09-12 (review of #169: ingest is not an agent/MCP +primitive). Originally decided 2026-09-10 (owner-approved product contract +for #168). **Rationale:** resume/import/image QA needs file inputs; the existing store already has the safety properties we need. Putting bytes on the wire would -blow the 1 MiB frame and leak file contents into logs. WebKit has no +blow the 1 MiB frame and leak file contents into logs. A protocol +`artifact.add` would let an agent ingest any readable host file, which +SECURITY.md classifies as reading outside the store. WebKit has no equivalent of `setFileInputFiles` without a JS hole. -**Consequences:** MCP can ingest because `artifact.add` is a protocol command -(MCP rejects local-only CLI). WebKit clients must skip upload or fail closed. -Replay of `upload` requires the same artifact basename still in the store. -`artifacts add` is not flow-recorded because it carries a local filesystem -path. +**Consequences:** MCP and the Unix socket cannot ingest. Operators copy +fixtures with the local CLI, then agents attach by basename. WebKit clients +must skip upload or fail closed. Replay of `upload` requires the same +artifact basename still in the store. `artifacts add` cannot be +flow-recorded because it is not a protocol command. **Revisit trigger:** a documented WKWebView/native attach API that does not execute page JS and does not pass file bytes through the JS bridge. @@ -768,7 +782,7 @@ rule that durable saved-credential retrieval needs trusted per-use presence. | 19 | Keep macOS agent startup behind the current app | Implemented | 2026-08-12 | | 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 | -| 23 | Artifact-store ingest + engine file attach; downloads denied | Decided | 2026-09-10 | +| 23 | Local CLI ingest + engine attach of store basenames; downloads denied | Decided (revised 2026-09-12) | 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 67fa810..4da10d2 100644 --- a/docs/roadmap/what-is-excellent.md +++ b/docs/roadmap/what-is-excellent.md @@ -72,8 +72,9 @@ prompt-injected or confused agent _cannot_ violate them. (`main.swift:733-748`). Dangerous remote extensions hard-blocked (25-entry list), archives surfaced as `caution` (`Protocol.swift:576-591`). Upload is the inverse: a user-supplied fixture is ingested into the private artifact - store (`artifact.add`) and attached by basename (`upload`). It is not a - download manager. + store by the local CLI (`artifacts add`, never a protocol or MCP command) + and attached by basename (`upload`). It is not a download manager. The + agent cannot 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`, @@ -87,9 +88,10 @@ 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` and - `artifact.add` (local paths). `upload` may record the artifact basename - only; replay files can never contain credentials (`Flows.swift`). +- **Typed values never persisted.** Flow recording excludes `fill`. Ingest + is a local CLI path, so it cannot be recorded. `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 From 3a31100be921285a6c2eadd89bebd8b109225602 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 12 Sep 2026 17:07:39 +0530 Subject: [PATCH 3/6] fix(security): keep uploads inside the artifact store --- .../references/commands.md | 8 +- .../references/safety.md | 11 +-- SECURITY.md | 2 +- apps/headless/MCP/main.swift | 8 -- apps/headless/Sources/HeadlessCLI/main.swift | 8 -- .../Sources/HeadlessProtocol/Artifacts.swift | 68 ------------- .../Sources/HeadlessProtocol/CLI.swift | 45 +-------- .../HeadlessProtocol/Capabilities.swift | 1 - .../Sources/HeadlessProtocol/Protocol.swift | 1 - .../Resources/AgentRuntime.js | 16 +-- .../Tests/HeadlessMCPTests/main.swift | 4 +- .../HeadlessProtocolTests/ProtocolTests.swift | 99 ++++++------------- apps/headless/Tests/agent-runtime.test.mjs | 15 ++- apps/headless/Tests/linux-e2e.sh | 17 +--- apps/headless/Tests/macos-e2e.sh | 10 +- apps/headless/docs/COMMANDS.md | 21 ++-- apps/headless/docs/P1.md | 17 ++++ docs/roadmap/architecture-decisions.md | 62 ++++++------ docs/roadmap/what-is-excellent.md | 18 ++-- 19 files changed, 133 insertions(+), 298 deletions(-) diff --git a/.agents/skills/headless-computer-use/references/commands.md b/.agents/skills/headless-computer-use/references/commands.md index 7182577..50d951c 100644 --- a/.agents/skills/headless-computer-use/references/commands.md +++ b/.agents/skills/headless-computer-use/references/commands.md @@ -45,10 +45,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. File inputs advertise `upload`; ingest the -fixture with `artifacts add` first, then `upload --artifact BASENAME`. Upload -never takes a home-directory path. Ask before uploading, as in -[safety.md](safety.md). +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 +77,6 @@ headless --session NAME record start --fps 10 --format mp4 --quality balanced headless --session NAME record status headless --session NAME record stop --output flow.mp4 headless --session NAME capture-info -headless artifacts add /abs/or/cwd/resume.pdf --name resume.pdf headless artifacts list ``` diff --git a/.agents/skills/headless-computer-use/references/safety.md b/.agents/skills/headless-computer-use/references/safety.md index aa72d96..1e83d84 100644 --- a/.agents/skills/headless-computer-use/references/safety.md +++ b/.agents/skills/headless-computer-use/references/safety.md @@ -27,13 +27,12 @@ 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, ingest it with -`headless artifacts add SOURCE --name FILE` and then +To attach a file after that confirmation, use `headless upload --role textbox --name NAME --artifact FILE` (or `upload @eN ---artifact FILE`). Upload accepts an artifact basename only — never a -home-directory path. Downloads remain denied. File bytes never appear on the -protocol socket. macOS WebKit returns `UNSUPPORTED_CAPABILITY` until a native -attach path exists. +--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/SECURITY.md b/SECURITY.md index 077dbea..d17377d 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. `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. Ingest of operator files is a local CLI path, never a protocol or MCP command. | +| **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/MCP/main.swift b/apps/headless/MCP/main.swift index 35e89e3..aa3004e 100644 --- a/apps/headless/MCP/main.swift +++ b/apps/headless/MCP/main.swift @@ -76,14 +76,6 @@ while let line = readLine() { ) continue } - if case .artifactsAdd = invocation.local { - toolResult( - id: id, - text: "Artifact ingest requires a local operator and is 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/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index 0b7995b..489f192 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -225,8 +225,6 @@ do { } case .credentials(let command): try CredentialBrokerLauncher().run(command) - case .artifactsAdd(let source, let name): - printJSON(try ArtifactStore().ingest(sourcePath: source, name: name)) } } else if let request = invocation.request { let launcher = HostLauncher() @@ -292,12 +290,6 @@ do { ) try? printResponse(response) exit(69) -} catch let error as ArtifactError { - let response = CommandResponse.failure( - id: "unknown", code: "ARTIFACT_ERROR", message: error.description - ) - try? printResponse(response) - exit(69) } catch { fputs("headless: \(error)\n", stderr) exit(70) diff --git a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift index f96f01a..3d5b488 100644 --- a/apps/headless/Sources/HeadlessProtocol/Artifacts.swift +++ b/apps/headless/Sources/HeadlessProtocol/Artifacts.swift @@ -222,21 +222,6 @@ public final class ArtifactStore: @unchecked Sendable { ]) } - /// Copies a local regular file into the store. Called only from the local - /// CLI process (same UID as the operator). File bytes never appear on the - /// Unix socket, and HostCore cannot reach this path. Symlinks, directories, - /// FIFOs, and oversized files fail closed. The stored object is always a - /// new `0600` regular file. - public func ingest(sourcePath: String, name: String) throws -> JSONValue { - do { try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) } - catch { throw ArtifactError.invalidName(name) } - let data = try readUploadSource(sourcePath) - let fileExtension = URL(fileURLWithPath: name).pathExtension.lowercased() - return try write( - data, requestedName: name, extension: fileExtension, prefix: "upload" - ) - } - /// 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( @@ -257,59 +242,6 @@ public final class ArtifactStore: @unchecked Sendable { return url } - private func readUploadSource(_ sourcePath: String) throws -> Data { - guard sourcePath.hasPrefix("/") else { - throw ArtifactError.writeFailed("Source must be an absolute path") - } - let sourceURL = URL(fileURLWithPath: sourcePath) - guard sourceURL.path.hasPrefix("/") else { - throw ArtifactError.writeFailed("Source must be an absolute path") - } - var info = stat() - guard lstat(sourceURL.path, &info) == 0 else { - throw ArtifactError.writeFailed("Source file is missing or unreadable") - } - guard (info.st_mode & S_IFMT) == S_IFREG else { - throw ArtifactError.writeFailed("Source must be a regular file") - } - let descriptor = open(sourceURL.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) - guard descriptor >= 0 else { - throw ArtifactError.writeFailed("Source file is missing or unreadable") - } - defer { _ = close(descriptor) } - var opened = stat() - guard fstat(descriptor, &opened) == 0, (opened.st_mode & S_IFMT) == S_IFREG else { - throw ArtifactError.writeFailed("Source must be a regular file") - } - // st_size is not the read bound. A growing or lying regular file is - // stopped at the limit without slurping the rest of the file. - return try readBounded( - descriptor: descriptor, maximumBytes: ProtocolBounds.artifactUploadBytes - ) - } - - private func readBounded(descriptor: Int32, maximumBytes: Int, chunkBytes: Int = 64 * 1_024) throws -> Data { - var data = Data() - var buffer = [UInt8](repeating: 0, count: max(1, chunkBytes)) - while true { - #if canImport(Darwin) - let count = Darwin.read(descriptor, &buffer, buffer.count) - #else - let count = Glibc.read(descriptor, &buffer, buffer.count) - #endif - if count < 0 && errno == EINTR { continue } - guard count >= 0 else { - throw ArtifactError.writeFailed("Source file is missing or unreadable") - } - if count == 0 { break } - if data.count + count > maximumBytes { - throw ArtifactError.writeFailed("Source file exceeds the 5 MiB upload limit") - } - data.append(buffer, count: count) - } - return data - } - /// 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 6887ced..3103e7e 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -21,7 +21,6 @@ public enum LocalCommand: Equatable, Sendable { case start(presentation: AgentStartupPresentation?) case config(ConfigCLICommand) case credentials(CredentialCLICommand) - case artifactsAdd(source: String, name: String) } public struct CLIInvocation: Equatable, Sendable { @@ -181,7 +180,8 @@ public struct CLIParser { case "screenshot": return try parseScreenshot(arguments, session: session, jsonOutput: jsonOutput) case "artifacts": - return try parseArtifacts(arguments, session: session, jsonOutput: jsonOutput) + guard arguments == ["list"] else { throw CLIParseError.missingArgument("artifacts list") } + return remote(.artifactList, session: session, jsonOutput: jsonOutput) case "record": return try parseRecord(arguments, session: session, jsonOutput: jsonOutput) case "qa": @@ -378,32 +378,6 @@ public struct CLIParser { return number } - private func parseArtifacts( - _ arguments: [String], session: String?, jsonOutput: Bool - ) throws -> CLIInvocation { - guard let subcommand = arguments.first else { - throw CLIParseError.missingArgument("artifacts list|add") - } - var args = Array(arguments.dropFirst()) - switch subcommand { - case "list": - try requireEmpty(args) - return remote(.artifactList, session: session, jsonOutput: jsonOutput) - case "add": - let name = try removeOption("--name", from: &args) - guard let source = args.first else { throw CLIParseError.missingArgument("SOURCE") } - guard args.count == 1 else { throw CLIParseError.invalidOption(args[1]) } - guard let name else { throw CLIParseError.missingArgument("--name") } - try validateArtifactName(name, expectedExtensions: uploadArtifactExtensions) - return CLIInvocation( - local: .artifactsAdd(source: try absoluteSourcePath(source), name: name), - jsonOutput: jsonOutput - ) - default: - throw CLIParseError.unknownCommand("artifacts \(subcommand)") - } - } - private func parseUpload( _ arguments: [String], session: String?, jsonOutput: Bool ) throws -> CLIInvocation { @@ -419,20 +393,6 @@ public struct CLIParser { return remote(.upload, session: session, parameters: parameters, jsonOutput: jsonOutput) } - private func absoluteSourcePath(_ value: String) throws -> String { - guard !value.isEmpty else { throw CLIParseError.missingArgument("SOURCE") } - let resolved: URL - if value.hasPrefix("/") { - resolved = URL(fileURLWithPath: value) - } else { - let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) - resolved = URL(fileURLWithPath: value, relativeTo: cwd) - } - let path = resolved.standardizedFileURL.path - guard path.hasPrefix("/") else { throw CLIParseError.invalidOption(value) } - return path - } - private func parseTargeted( _ command: CommandName, arguments: [String], @@ -858,7 +818,6 @@ Commands: screenshot --full-page --format pdf [--output FILE.pdf] screenshot --every-viewport|--by-section [--format png|jpg|jpeg] [--output PREFIX] artifacts list - artifacts add SOURCE --name FILE record start [--fps N] [--format mp4|mov|webm|gif] [--quality fast|balanced|high] [--output FILE] record status | record stop [--output FILE] qa report | qa clear diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index 1472fca..ab0d9df 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -205,7 +205,6 @@ public let capabilitiesDocument: JSONValue = { ]), "screenshotSeries": stringArray(["viewport", "section"]), "localCommands": stringArray([ - "artifacts.add", "config.describe", "config.get", "config.list", "config.reset", "config.set", "credentials.add", "credentials.list", "credentials.remove", "credentials.rename", ]), diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 999971d..7c05b27 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -590,7 +590,6 @@ public enum ProtocolBounds { public static let networkThroughputKbps = -1.0...1_000_000.0 public static let screenshotDimension = 16_384.0 public static let screenshotPixels = 64_000_000.0 - public static let artifactUploadBytes = 5 * 1_024 * 1_024 } public struct BoundedScreenshotRectangle: Equatable, Sendable { diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index 3bc5f86..95b64b5 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -620,9 +620,7 @@ if (!globalThis.__headlessAgent) { if (element.disabled || element.getAttribute('aria-disabled') === 'true') { fail('NOT_EDITABLE', 'NOT_EDITABLE: file input is disabled'); } - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - if (style.display === 'none' || style.visibility === 'hidden' || rect.width <= 0 || rect.height <= 0) { + if (!visible(element)) { fail('ELEMENT_NOT_VISIBLE', 'ELEMENT_NOT_VISIBLE: file input is not visible'); } return element; @@ -635,16 +633,6 @@ if (!globalThis.__headlessAgent) { name: name(element), }; }; - const fileInputResult = args => { - const element = fileInput(args); - const files = Array.from(element.files || []).map(file => String(file && file.name || '').slice(0, 128)); - return { - uploaded: refFor(element), - role: role(element), - name: name(element), - files, - }; - }; const fill = args => { const element = target(args); if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable)) { @@ -954,7 +942,7 @@ if (!globalThis.__headlessAgent) { return {count: document.getAnimations().length, animations: all, truncated: document.getAnimations().length > all.length}; }; return { - snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputPrepare, fileInputResult, + snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputPrepare, authentication, scroll, state, tour, screenshotPlan, scrollToCapturePoint, rectangle, styles, storage, performance: performanceSummary, animations }; diff --git a/apps/headless/Tests/HeadlessMCPTests/main.swift b/apps/headless/Tests/HeadlessMCPTests/main.swift index ccfea2b..bf0a6b9 100644 --- a/apps/headless/Tests/HeadlessMCPTests/main.swift +++ b/apps/headless/Tests/HeadlessMCPTests/main.swift @@ -194,8 +194,8 @@ func run() throws { throw TestFailure(description: "artifact ingest rejection text was absent") } try expect( - ingestText.contains("local operator") && ingestText.contains("unavailable over MCP"), - "artifact ingest rejection should require a local operator" + ingestText.contains("artifacts list"), + "artifact ingest rejection should expose only the supported list command" ) } diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 08dcdb5..24ed55a 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1063,9 +1063,6 @@ struct ProtocolTests { (["credentials", "list", "--origin", "https://example.com"], .credentials(.list( origin: try CredentialOrigin(rawValue: "https://example.com") ))), - (["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"], .artifactsAdd( - source: "/tmp/resume.pdf", name: "resume.pdf" - )), (["help"], .help), (["--help"], .help), (["version"], .version), @@ -2168,10 +2165,13 @@ struct ProtocolTests { let localCommandNames = Set(localCommands.compactMap(\.stringValue)) try expect( localCommandNames.isSuperset(of: [ - "artifacts.add", "config.describe", "config.get", "config.list", "config.reset", "config.set", ]), - "capabilities should advertise every local config and ingest command" + "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 { @@ -3320,22 +3320,6 @@ struct ProtocolTests { } static func artifactUploadCommands() throws { - let add = try CLIParser().parse(["artifacts", "add", "/tmp/resume.pdf", "--name", "resume.pdf"]) - try expect(add.request == nil, "artifacts add must not become a socket command") - try expect( - add.local == .artifactsAdd(source: "/tmp/resume.pdf", name: "resume.pdf"), - "artifacts add should stay a local CLI ingest" - ) - - let relative = try CLIParser().parse(["artifacts", "add", "resume.pdf", "--name", "resume.pdf"]) - guard case .artifactsAdd(let resolved, let relativeName) = relative.local else { - throw TestFailure(description: "relative artifacts add should stay local") - } - try expect(relative.request == nil, "relative artifacts add must not become a socket command") - try expect(resolved.hasPrefix("/"), "CLI must resolve cwd-relative sources to absolute paths") - try expect(resolved.hasSuffix("/resume.pdf") || resolved.hasSuffix("resume.pdf"), "resolved source should keep the basename") - try expect(relativeName == "resume.pdf", "destination name should parse") - let semantic = try CLIParser().parse([ "upload", "--role", "textbox", "--name", "Resume", "--artifact", "resume.pdf", ]) @@ -3354,11 +3338,8 @@ struct ProtocolTests { try expectThrows("upload without --artifact should fail in the CLI") { _ = try CLIParser().parse(["upload", "@e12"]) } - try expectThrows("artifacts add without --name should fail") { - _ = try CLIParser().parse(["artifacts", "add", "/tmp/resume.pdf"]) - } - try expectThrows("CLI should reject html ingest names") { - _ = try CLIParser().parse(["artifacts", "add", "/tmp/page.html", "--name", "page.html"]) + 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") { @@ -3393,31 +3374,26 @@ struct ProtocolTests { } let root = "/tmp/headless-upload-artifact-\(UUID().uuidString)" - let sourceDir = root + "-src" + let outsideArtifact = root + "-outside.png" defer { try? FileManager.default.removeItem(atPath: root) - try? FileManager.default.removeItem(atPath: sourceDir) + try? FileManager.default.removeItem(atPath: outsideArtifact) } - try FileManager.default.createDirectory(atPath: sourceDir, withIntermediateDirectories: true) let store = try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": root]) - let pngPath = sourceDir + "/tiny.png" - try tinyPNG.write(to: URL(fileURLWithPath: pngPath)) - let added = try store.ingest(sourcePath: pngPath, name: "tiny.png") + let added = try store.write( + tinyPNG, requestedName: "tiny.png", extension: "png", prefix: "test" + ) guard case .object(let addedMetadata) = added else { - throw TestFailure(description: "ingest metadata") + throw TestFailure(description: "artifact metadata") } - try expect(addedMetadata["name"] == .string("tiny.png"), "ingest should return the destination name") - try expect(addedMetadata["kind"] == .string("png"), "ingest should report the file kind") - try expect( - try Data(contentsOf: URL(fileURLWithPath: root + "/tiny.png")) == tinyPNG, - "ingest should copy source bytes into a regular store file" - ) + 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, "ingested artifact should be private") + try expect(pngMode == 0o600, "stored upload artifact should be private") - let txtPath = sourceDir + "/notes.txt" - try Data("hello".utf8).write(to: URL(fileURLWithPath: txtPath)) - _ = try store.ingest(sourcePath: txtPath, name: "notes.txt") + _ = 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") @@ -3426,28 +3402,8 @@ struct ProtocolTests { guard case .object(let object) = value else { return nil } return object["name"]?.stringValue } - try expect(listedNames.contains("tiny.png"), "listing should include ingested png") - try expect(listedNames.contains("notes.txt"), "listing should include ingested txt") - - try expectThrows("ingest overwrite should fail closed") { - _ = try store.ingest(sourcePath: pngPath, name: "tiny.png") - } - let linkPath = sourceDir + "/link.png" - try FileManager.default.createSymbolicLink(atPath: linkPath, withDestinationPath: pngPath) - try expectThrows("symlink sources should be rejected") { - _ = try store.ingest(sourcePath: linkPath, name: "from-link.png") - } - try expectThrows("directory sources should be rejected") { - _ = try store.ingest(sourcePath: sourceDir, name: "folder.png") - } - let hugePath = sourceDir + "/huge.txt" - try Data(count: ProtocolBounds.artifactUploadBytes + 1).write(to: URL(fileURLWithPath: hugePath)) - try expectThrows("oversized ingest should fail from the chunked reader, not a trusted st_size") { - _ = try store.ingest(sourcePath: hugePath, name: "huge.txt") - } - try expectThrows("missing source should fail closed") { - _ = try store.ingest(sourcePath: sourceDir + "/missing.txt", name: "missing.txt") - } + 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, @@ -3456,6 +3412,13 @@ struct ProtocolTests { 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() @@ -3467,7 +3430,7 @@ struct ProtocolTests { ) defer { core.stop() } - try expect(session.agentControlEnableCount == 0, "local ingest must not enable page control") + 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")] @@ -3475,7 +3438,7 @@ struct ProtocolTests { 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(pngPath), "upload responses must not include the source path") + 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") @@ -3565,7 +3528,7 @@ struct ProtocolTests { ("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle), ("host authentication orchestration", hostAuthenticationOrchestration), ("docs command reference matches help", docsCommandReferenceMatchesHelp), - ("artifact ingest and file upload", artifactUploadCommands), + ("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 40d7aee..6cd1de8 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -425,7 +425,6 @@ assert.equal(fileItem?.actions?.length, 1); assert.equal(fileItem?.actions?.[0], 'upload'); assert.equal(agent.fileInput({role: 'textbox', name: 'Resume'}), fileInput); assert.equal(agent.fileInputPrepare({role: 'textbox', name: 'Resume'}).uploaded, fileItem.ref); -assert.equal(agent.fileInputResult({role: 'textbox', name: 'Resume'}).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), @@ -452,6 +451,12 @@ assert.throws( 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 = ''; fileInput.getBoundingClientRect = () => ({ x: 20, y: 140, top: 140, left: 20, right: 20, bottom: 140, width: 0, height: 0, }); @@ -459,6 +464,14 @@ 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, +}); +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, diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 00607ee..88c19da 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -304,22 +304,13 @@ 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' > "$FIXTURE_ROOT/resume.txt" -ADD="$(headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.txt)" -echo "$ADD" | grep -q '"name":"resume.txt"' -echo "$ADD" | grep -q '"kind":"txt"' +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" -printf 'cover-letter\n' > "$FIXTURE_ROOT/cover.txt" -RELATIVE_ADD="$(cd "$FIXTURE_ROOT" && headless artifacts add ./cover.txt --name cover.txt)" -echo "$RELATIVE_ADD" | grep -q '"name":"cover.txt"' headless artifacts list | grep -q '"name":"resume.txt"' -if headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.txt >/dev/null 2>&1; then - echo "artifact ingest overwrite was not rejected" >&2 - exit 1 -fi -if headless artifacts add "$FIXTURE_ROOT/resume.txt" --name resume.html >/dev/null 2>&1; then - echo "html artifact ingest was not rejected" >&2 +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' diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 234c6bc..918958d 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -329,9 +329,12 @@ if NETWORK_SIMULATION="$("$CLI" --session qa network emulate --latency 25)"; the fi echo "$NETWORK_SIMULATION" | grep -q 'UNSUPPORTED_CAPABILITY' STEP="file-upload-unsupported" -UPLOAD_SOURCE="$(mktemp "${TMPDIR:-/tmp}/headless-upload-source.XXXXXX")" -printf 'resume-fixture\n' > "$UPLOAD_SOURCE" -"$CLI" artifacts add "$UPLOAD_SOURCE" --name resume.txt | grep -q '"name":"resume.txt"' +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"' @@ -344,7 +347,6 @@ if UPLOAD="$("$CLI" --session qa upload --role textbox --name Resume --artifact fail fi echo "$UPLOAD" | grep -q 'UNSUPPORTED_CAPABILITY' -rm -f "$UPLOAD_SOURCE" 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' diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index 6b04e28..c30986e 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -194,11 +194,12 @@ back | reload - `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 + `--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. Ingest the fixture first with the local CLI - `artifacts add`; that path is not a protocol or MCP command. + 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. @@ -210,7 +211,6 @@ screenshot [REF | --role ROLE --name NAME | --full-page] [--format png|jpg|jpeg] screenshot --full-page --format pdf [--output FILE.pdf] screenshot --every-viewport|--by-section [--format png|jpg|jpeg] [--output PREFIX] artifacts list -artifacts add SOURCE --name FILE record start [--fps N] [--format mp4|mov|webm|gif] [--quality fast|balanced|high] [--output FILE] record status | record stop [--output FILE] qa report | qa clear @@ -220,13 +220,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 add` copies a local regular file (absolute or cwd-relative; 5 MiB - cap; `pdf`/`png`/`jpg`/`jpeg`/`gif`/`webp`/`txt`/`csv`/`json` only) into that - store as a new `0600` name. It runs in the CLI process as a local operator - path, like `credentials`; it is not a socket command and MCP rejects it. - HTML, SVG, executables, and archives are rejected. The stored object is - bytes, not a symlink. `artifacts list` remains a protocol command. `upload` - then names that basename; it is not a download manager. +- `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 @@ -260,8 +256,7 @@ flow start | flow stop [--output FLOW.json] | flow run FLOW.json 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. `upload` may be recorded with the artifact - basename only; replay needs that same store name. `artifacts add` is never - recorded because it is a local CLI path, not a protocol command. + 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 7fd66a0..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 diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index fea1435..4a6b2ea 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -487,22 +487,15 @@ no TCP listener, fail closed, bounded everything. --- -## 23. File upload is local ingest plus engine attach of store basenames +## 23. File upload attaches existing store basenames only -**Decision:** ingest of a local file into the private artifact store is a -local CLI/TTY operator path (`headless artifacts add`), never a protocol -command and never available over MCP. The agent cannot read outside the -artifact store. `upload` is a protocol command that names a basename already -in the store and asks the engine to attach that on-disk file. File bytes +**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. - -`artifacts add` copies a local regular file into the store in the CLI process -(same UID as the operator): validated basename, `O_EXCL`, `0600`, 5 MiB -chunked read that does not trust `st_size` as the read bound, allow-listed -extensions. `artifacts list` remains a protocol command. `upload` targets a -file input with the same grammar as `click`. +`upload` targets a file input with the same grammar as `click`. Linux Chromium attaches via `DOM.setFileInputFiles` using an isolated-world objectId. Attachment success is completion: bounded `{ref, role, name}` @@ -513,25 +506,30 @@ evaluate page JavaScript or shuttle file bytes through JS. Capabilities declare `fileUpload` accordingly; inspect advertises `upload` only when that flag is true. -**Status:** revised 2026-09-12 (review of #169: ingest is not an agent/MCP -primitive). Originally decided 2026-09-10 (owner-approved product contract -for #168). - -**Rationale:** resume/import/image QA needs file inputs; the existing store -already has the safety properties we need. Putting bytes on the wire would -blow the 1 MiB frame and leak file contents into logs. A protocol -`artifact.add` would let an agent ingest any readable host file, which -SECURITY.md classifies as reading outside the store. WebKit has no -equivalent of `setFileInputFiles` without a JS hole. - -**Consequences:** MCP and the Unix socket cannot ingest. Operators copy -fixtures with the local CLI, then agents attach by basename. WebKit clients -must skip upload or fail closed. Replay of `upload` requires the same -artifact basename still in the store. `artifacts add` cannot be -flow-recorded because it is not a protocol command. - -**Revisit trigger:** a documented WKWebView/native attach API that does not -execute page JS and does not pass file bytes through the JS bridge. +**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. --- @@ -782,7 +780,7 @@ rule that durable saved-credential retrieval needs trusted per-use presence. | 19 | Keep macOS agent startup behind the current app | Implemented | 2026-08-12 | | 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 | -| 23 | Local CLI ingest + engine attach of store basenames; downloads denied | Decided (revised 2026-09-12) | 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 4da10d2..93fcd37 100644 --- a/docs/roadmap/what-is-excellent.md +++ b/docs/roadmap/what-is-excellent.md @@ -29,7 +29,7 @@ never on CSS selectors they invented. role/name is the preferred stable form; action hints (`actions: ["click"]`) 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 +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 @@ -70,11 +70,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`). Upload is - the inverse: a user-supplied fixture is ingested into the private artifact - store by the local CLI (`artifacts add`, never a protocol or MCP command) - and attached by basename (`upload`). It is not a download manager. The - agent cannot read outside the store. + 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`, @@ -88,10 +87,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`. Ingest - is a local CLI path, so it cannot be recorded. `upload` may record the - artifact basename only; replay files can never contain credentials - (`Flows.swift`). +- **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 From 71d73e2bd860236e129a6ec20a6f79c4dce7786c Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 12 Sep 2026 17:28:03 +0530 Subject: [PATCH 4/6] fix(security): pin validated upload targets --- apps/headless/LinuxHost/BrowserProcess.swift | 25 +++++++--- .../Resources/AgentRuntime.js | 50 +++++++++++++++---- apps/headless/Tests/agent-runtime.test.mjs | 22 +++++++- docs/roadmap/architecture-decisions.md | 5 ++ 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index ddcb227..175a587 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -561,18 +561,14 @@ final class LinuxBrowserSession: @unchecked Sendable { func upload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue { let args = try browserTargetArguments(parameters) - let prepared = try evaluate( - "return globalThis.__headlessAgent.fileInputPrepare(args);", - input: ["args": args] - ) - guard case .object(var metadata) = prepared else { - throw CDPError.invalidResponse("file input metadata") - } 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], @@ -1071,6 +1067,21 @@ final class LinuxBrowserSession: @unchecked Sendable { 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 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) diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index 95b64b5..520cf5d 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -50,11 +50,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); @@ -609,8 +637,7 @@ 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 fileInput = args => { - const element = target(args); + const checkedFileInput = element => { const type = element instanceof HTMLInputElement ? String(element.getAttribute('type') || '').toLowerCase() : ''; @@ -620,17 +647,18 @@ if (!globalThis.__headlessAgent) { if (element.disabled || element.getAttribute('aria-disabled') === 'true') { fail('NOT_EDITABLE', 'NOT_EDITABLE: file input is disabled'); } - if (!visible(element)) { + if (!uploadVisible(element)) { fail('ELEMENT_NOT_VISIBLE', 'ELEMENT_NOT_VISIBLE: file input is not visible'); } return element; }; - const fileInputPrepare = args => { - const element = fileInput(args); + const fileInput = args => checkedFileInput(target(args)); + const fileInputMetadata = element => { + const checked = checkedFileInput(element); return { - uploaded: refFor(element), - role: role(element), - name: name(element), + uploaded: refFor(checked), + role: role(checked), + name: name(checked), }; }; const fill = args => { @@ -942,7 +970,7 @@ if (!globalThis.__headlessAgent) { return {count: document.getAnimations().length, animations: all, truncated: document.getAnimations().length > all.length}; }; return { - snapshot, click, fill, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputPrepare, + 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/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index 6cd1de8..940bd23 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -408,6 +408,7 @@ 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( @@ -424,7 +425,7 @@ 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.fileInputPrepare({role: 'textbox', name: 'Resume'}).uploaded, fileItem.ref); +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), @@ -457,6 +458,19 @@ assert.throws( 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, }); @@ -467,6 +481,12 @@ assert.throws( 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}), diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 4a6b2ea..f7abc20 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -497,6 +497,11 @@ 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 From 66db6b8ae88bd29298a1deca9f7a0e13e3320459 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 12 Sep 2026 17:37:52 +0530 Subject: [PATCH 5/6] fix(linux): propagate upload metadata decoding errors --- apps/headless/LinuxHost/BrowserProcess.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index 673d7bb..72b0f99 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -1279,7 +1279,7 @@ final class LinuxBrowserSession: @unchecked Sendable { guard let result = response["result"] as? [String: Any], let value = result["value"] else { throw CDPError.invalidResponse("file input metadata") } - return JSONValue.foundationValue(value) + return try JSONValue.foundationValue(value) } private func hostError(fromCDPException exception: [String: Any]) -> HostError { From a6ef4e9b0f3c2a144671a03abaa555532dcee12d Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 12 Sep 2026 17:50:24 +0530 Subject: [PATCH 6/6] test(macos): pinpoint durable profile failures --- apps/headless/Tests/macos-e2e.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 3140ae7..5675725 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -485,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 @@ -499,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 @@ -517,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'