Skip to content

Commit ab4e3e8

Browse files
authored
Merge pull request #169 from LockInTime/feat/artifact-upload
feat(protocol): attach private artifacts to file inputs
2 parents 75f1315 + a6ef4e9 commit ab4e3e8

27 files changed

Lines changed: 796 additions & 42 deletions

File tree

‎.agents/skills/headless-computer-use/SKILL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ controls the desktop, native applications, OS chrome, microphone, or system audi
2121
selection, Docker access, visible-browser mode, or MCP configuration matters.
2222

2323
Run `headless capabilities` once before relying on optional features. Network
24-
emulation and mocking are Linux Chromium capabilities; macOS WebKit reports them
25-
as unsupported.
24+
emulation, request mocking, and file upload are Linux Chromium capabilities;
25+
macOS WebKit reports them as unsupported.
2626

2727
## Follow the mandatory interaction loop
2828

‎.agents/skills/headless-computer-use/references/commands.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ headless --session NAME click REF
3030
headless --session NAME click --role ROLE --name NAME
3131
headless --session NAME fill REF "TEXT"
3232
headless --session NAME fill REF -- "--json stays literal"
33+
headless --session NAME upload REF --artifact FILE
34+
headless --session NAME upload --role textbox --name NAME --artifact FILE
3335
headless --session NAME press KEY
3436
headless --session NAME scroll up|down|top|bottom --amount PIXELS
3537
headless --session NAME back
@@ -44,7 +46,9 @@ large pages, request `outline`, select a returned `@rN` region, then use
4446
bound the result; check `omitted` before assuming it describes the whole page.
4547
Use `click --role ... --name ...` for unique accessible controls. Use an `@eN`
4648
ref from the latest inspection when role/name is ambiguous. Inspect again after
47-
navigation or a large rerender.
49+
navigation or a large rerender. File inputs advertise `upload` for an existing
50+
private artifact-store basename. Upload never accepts or imports a filesystem
51+
path. Ask before uploading, as in [safety.md](safety.md).
4852

4953
Pass fill text as one quoted shell argument so whitespace is preserved. Put
5054
`--` before a value that contains a literal global flag such as `--json` or
@@ -78,7 +82,8 @@ headless artifacts list
7882
```
7983

8084
Single artifact output names are basenames ending in `.png`, `.jpg`, `.jpeg`,
81-
`.pdf`, `.mp4`, `.mov`, `.webm`, `.gif`, or `.json`. Screenshot series output
85+
`.gif`, `.webp`, `.txt`, `.csv`, `.pdf`, `.mp4`, `.mov`, `.webm`, or `.json`.
86+
Screenshot series output
8287
uses a safe prefix and creates numbered PNG/JPG artifacts. Headless refuses
8388
paths and overwrites. Built-in recording captures browser pixels only.
8489

‎.agents/skills/headless-computer-use/references/safety.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ publishes content, makes a purchase, changes permissions, accepts legal terms,
3232
uploads a file, or otherwise creates a meaningful external effect not already
3333
explicitly authorized by the user.
3434

35+
To attach a file after that confirmation, use
36+
`headless upload --role textbox --name NAME --artifact FILE` (or `upload @eN
37+
--artifact FILE`) with an existing private artifact-store basename. No
38+
agent-facing command imports a local filesystem path. Downloads remain denied.
39+
File bytes never appear on the protocol socket. macOS WebKit returns
40+
`UNSUPPORTED_CAPABILITY` until a native attach path exists.
41+
3542
Routine mutations inside an explicitly requested disposable/local E2E test are
3643
in scope. Do not transfer that authorization to a production site.
3744

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ structural region references such as `@r4`, then inspect only that region with
124124
`contextStats` make pruning explicit. `--context full --text` remains the
125125
explicit broad-page escape hatch.
126126

127-
Each control's `actions` list contains only protocol verbs that can run (`click`
128-
or `fill`); unsupported controls never advertise nonexistent commands. Full
127+
Each control's `actions` list contains only protocol verbs that can run (`click`,
128+
`fill`, or `upload`); unsupported controls never advertise nonexistent commands. Full
129129
inspection retains roles, names, rendered media metadata, safety markers,
130130
bounds, and element references such as `@e1`.
131131
`capture-info` returns the browser surface, page state, action trace, and

‎SECURITY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ These are host-enforced contracts. Anything that defeats one is in scope:
3434
| **Navigation** | HTTP/HTTPS only. Optional `headless start --allow` host allowlist. `file:`, `javascript:`, `data:`, credential-bearing URLs, and external application schemes must be refused at every layer. |
3535
| **Downloads** | Page-initiated downloads are denied. Executables, installers, scripts, libraries, and disk images are blocked by extension. |
3636
| **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. |
37-
| **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. |
37+
| **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. |
3838
| **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. |
3939
| **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. |
4040
| **Sandbox** | The Linux host refuses to run as root and never passes `--no-sandbox`. Snap Chromium is rejected before launch. |

‎apps/headless/Host/AgentBridge.swift‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,10 @@ extension BrowserWindowController {
465465
// WKWebView can expose its initial about:blank document before
466466
// document-start scripts run. Install once in that document, then
467467
// subsequent calls use the cached isolated-world runtime.
468-
return try evaluate(agentRuntimeJavaScript + "\n" + agentEvaluationBody(body))
468+
return try evaluate(
469+
"globalThis.__headlessFileUpload = false;\n" + agentRuntimeJavaScript
470+
+ "\n" + agentEvaluationBody(body)
471+
)
469472
}
470473
}
471474
}

‎apps/headless/LinuxHost/BrowserProcess.swift‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import Darwin
66
import Glibc
77
#endif
88

9+
private let linuxAgentRuntimeJavaScript = "globalThis.__headlessFileUpload = true;\n" + agentRuntimeJavaScript
10+
911
private final class ChromiumChildProcess {
1012
let processIdentifier: Int32
1113
private let lock = NSLock()
@@ -629,9 +631,10 @@ final class LinuxBrowserSession: @unchecked Sendable {
629631
self.connection = connection
630632
_ = try command("Page.enable")
631633
_ = try command("Runtime.enable")
634+
_ = try command("DOM.enable")
632635
_ = try command("Log.enable")
633636
_ = try command("Page.addScriptToEvaluateOnNewDocument", parameters: [
634-
"source": agentRuntimeJavaScript,
637+
"source": linuxAgentRuntimeJavaScript,
635638
"worldName": "HeadlessAgent",
636639
"runImmediately": true,
637640
])
@@ -750,6 +753,24 @@ final class LinuxBrowserSession: @unchecked Sendable {
750753
])
751754
}
752755

756+
func upload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue {
757+
let args = try browserTargetArguments(parameters)
758+
let objectId = try evaluateNode(
759+
"return globalThis.__headlessAgent.fileInput(args);",
760+
input: ["args": args]
761+
)
762+
defer { _ = try? command("Runtime.releaseObject", parameters: ["objectId": objectId]) }
763+
guard case .object(var metadata) = try fileInputMetadata(objectId: objectId) else {
764+
throw CDPError.invalidResponse("file input metadata")
765+
}
766+
_ = try command("DOM.setFileInputFiles", parameters: [
767+
"objectId": objectId,
768+
"files": [artifactURL.path],
769+
])
770+
metadata["artifact"] = .string(artifactURL.lastPathComponent)
771+
return .object(metadata)
772+
}
773+
753774
func fill(parameters: [String: JSONValue]) throws -> JSONValue {
754775
guard let value = parameters["value"]?.stringValue else { throw CDPError.commandFailed("missing value") }
755776
let target = try trustedInputTarget(parameters: parameters, action: "fill")
@@ -1205,6 +1226,74 @@ final class LinuxBrowserSession: @unchecked Sendable {
12051226
]
12061227
}
12071228

1229+
/// Runtime.evaluate with returnByValue false so a DOM node keeps its
1230+
/// objectId for `DOM.setFileInputFiles`. The existing `evaluate` helper
1231+
/// always returns JSON and cannot yield a node handle.
1232+
private func evaluateNode(_ body: String, input: [String: Any] = [:]) throws -> String {
1233+
let inputData = try JSONSerialization.data(withJSONObject: input, options: [.sortedKeys])
1234+
guard let inputJSON = String(data: inputData, encoding: .utf8) else {
1235+
throw CDPError.invalidResponse("input encoding")
1236+
}
1237+
let expression = """
1238+
(() => {
1239+
const __input = \(inputJSON);
1240+
const args = __input.args;
1241+
\(body)
1242+
})()
1243+
"""
1244+
func evaluateParameters() throws -> [String: Any] {
1245+
[
1246+
"expression": expression,
1247+
"returnByValue": false,
1248+
"userGesture": true,
1249+
"contextId": try isolatedExecutionContextID(),
1250+
]
1251+
}
1252+
let response: [String: Any]
1253+
do {
1254+
response = try command("Runtime.evaluate", parameters: try evaluateParameters())
1255+
} catch let error as CDPError where isTransientNavigationContext(error) {
1256+
clearIsolatedContext()
1257+
response = try command("Runtime.evaluate", parameters: try evaluateParameters())
1258+
}
1259+
if let exception = response["exceptionDetails"] as? [String: Any] {
1260+
throw hostError(fromCDPException: exception)
1261+
}
1262+
guard let result = response["result"] as? [String: Any],
1263+
result["subtype"] as? String == "node",
1264+
let objectId = result["objectId"] as? String, !objectId.isEmpty else {
1265+
throw CDPError.invalidResponse("file input objectId")
1266+
}
1267+
return objectId
1268+
}
1269+
1270+
private func fileInputMetadata(objectId: String) throws -> JSONValue {
1271+
let response = try command("Runtime.callFunctionOn", parameters: [
1272+
"objectId": objectId,
1273+
"functionDeclaration": "function() { return globalThis.__headlessAgent.fileInputMetadata(this); }",
1274+
"returnByValue": true,
1275+
])
1276+
if let exception = response["exceptionDetails"] as? [String: Any] {
1277+
throw hostError(fromCDPException: exception)
1278+
}
1279+
guard let result = response["result"] as? [String: Any], let value = result["value"] else {
1280+
throw CDPError.invalidResponse("file input metadata")
1281+
}
1282+
return try JSONValue.foundationValue(value)
1283+
}
1284+
1285+
private func hostError(fromCDPException exception: [String: Any]) -> HostError {
1286+
let description = ((exception["exception"] as? [String: Any])?["description"] as? String)
1287+
?? (exception["text"] as? String)
1288+
?? "Browser operation failed"
1289+
let firstLine = description.split(whereSeparator: \.isNewline).first.map(String.init) ?? description
1290+
let trimmed = firstLine.hasPrefix("Error: ") ? String(firstLine.dropFirst(7)) : firstLine
1291+
let codeText = trimmed.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
1292+
.first.map(String.init) ?? ""
1293+
let code = HostErrorCode(rawValue: codeText) ?? .operationFailed
1294+
return HostError(code: code, message: String(decoding: trimmed.utf8.prefix(4_096), as: UTF8.self))
1295+
}
1296+
12081297
private func evaluate(_ body: String, input: [String: Any] = [:], timeout: TimeInterval = 10) throws -> JSONValue {
12091298
let timeoutMilliseconds = Int32(min(125_000, max(1, ceil(timeout * 1_000))))
12101299
let inputData = try JSONSerialization.data(withJSONObject: input, options: [.sortedKeys])
@@ -1291,7 +1380,7 @@ final class LinuxBrowserSession: @unchecked Sendable {
12911380
let installedValue = (installed["result"] as? [String: Any])?["value"] as? Bool ?? false
12921381
if !installedValue {
12931382
_ = try command("Runtime.evaluate", parameters: [
1294-
"expression": agentRuntimeJavaScript,
1383+
"expression": linuxAgentRuntimeJavaScript,
12951384
"returnByValue": true,
12961385
"contextId": identifier,
12971386
])

‎apps/headless/LinuxHost/main.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession {
8787
func hostFill(parameters: [String: JSONValue]) throws -> JSONValue {
8888
try browserSession.fill(parameters: parameters)
8989
}
90+
func hostUpload(parameters: [String: JSONValue], artifactURL: URL) throws -> JSONValue {
91+
try browserSession.upload(parameters: parameters, artifactURL: artifactURL)
92+
}
9093
func hostPress(parameters: [String: JSONValue]) throws -> JSONValue {
9194
try browserSession.press(parameters: parameters)
9295
}

‎apps/headless/Sources/HeadlessProtocol/Artifacts.swift‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ public final class ArtifactStore: @unchecked Sendable {
207207
private static let listedExtensions: Set<String> =
208208
ScreenshotFormat.artifactExtensions
209209
.union(RecordingFormat.artifactExtensions)
210-
.union(["json"])
210+
.union(uploadArtifactExtensions)
211211

212212
private func metadata(for url: URL) throws -> JSONValue {
213213
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
@@ -222,6 +222,26 @@ public final class ArtifactStore: @unchecked Sendable {
222222
])
223223
}
224224

225+
/// Resolves an already-stored upload artifact to its on-disk URL. Callers
226+
/// receive a path inside this store only, never an agent-supplied path.
227+
public func urlForExistingArtifact(
228+
name: String, allowedExtensions: Set<String> = uploadArtifactExtensions
229+
) throws -> URL {
230+
lock.lock(); defer { lock.unlock() }
231+
do { try validateArtifactName(name, expectedExtensions: allowedExtensions) }
232+
catch { throw ArtifactError.invalidName(name) }
233+
let url = rootURL.appendingPathComponent(name, isDirectory: false)
234+
guard url.deletingLastPathComponent().standardizedFileURL == rootURL.standardizedFileURL else {
235+
throw ArtifactError.invalidName(name)
236+
}
237+
var info = stat()
238+
guard lstat(url.path, &info) == 0 else { throw ArtifactError.missing(name) }
239+
guard (info.st_mode & S_IFMT) == S_IFREG else {
240+
throw ArtifactError.writeFailed("Artifact is not a permitted regular file")
241+
}
242+
return url
243+
}
244+
225245
/// Reads only a regular artifact owned by this store. Callers never receive
226246
/// a path supplied by the agent, preventing an artifact command from
227247
/// becoming an arbitrary-file read primitive.

‎apps/headless/Sources/HeadlessProtocol/CLI.swift‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ public struct CLIParser {
143143
return try parseInspect(arguments, session: session, jsonOutput: jsonOutput)
144144
case "click":
145145
return try parseTargeted(.click, arguments: arguments, session: session, jsonOutput: jsonOutput)
146+
case "upload":
147+
return try parseUpload(arguments, session: session, jsonOutput: jsonOutput)
146148
case "fill":
147149
guard arguments.count == 2 else { throw CLIParseError.missingArgument("TARGET TEXT") }
148150
return remote(.fill, session: session, parameters: [
@@ -367,6 +369,21 @@ public struct CLIParser {
367369
return number
368370
}
369371

372+
private func parseUpload(
373+
_ arguments: [String], session: String?, jsonOutput: Bool
374+
) throws -> CLIInvocation {
375+
var args = arguments
376+
let artifact = try removeOption("--artifact", from: &args)
377+
guard let artifact else { throw CLIParseError.missingArgument("--artifact") }
378+
try validateArtifactName(artifact, expectedExtensions: uploadArtifactExtensions)
379+
let invocation = try parseTargeted(
380+
.upload, arguments: args, session: session, jsonOutput: jsonOutput
381+
)
382+
var parameters = invocation.request?.parameters ?? [:]
383+
parameters["artifact"] = .string(artifact)
384+
return remote(.upload, session: session, parameters: parameters, jsonOutput: jsonOutput)
385+
}
386+
370387
private func parseTargeted(
371388
_ command: CommandName,
372389
arguments: [String],
@@ -808,6 +825,7 @@ Commands:
808825
[--within @rN] [--limit N] [--budget TOKENS] [--depth N] [--text]
809826
click REF | click --role ROLE [--name NAME]
810827
fill REF TEXT | fill REF -- TEXT_WITH_LITERAL_FLAGS | press KEY
828+
upload REF --artifact FILE | upload --role ROLE [--name NAME] --artifact FILE
811829
scroll [up|down|top|bottom] [--amount PX]
812830
back | reload
813831
wait [--settled] [--url PATTERN] [--text TEXT] [--timeout MS]

0 commit comments

Comments
 (0)