Skip to content

Commit f24257b

Browse files
authored
feat(desktop): write ordered clipboard file references (#147)
1 parent 6bddf0f commit f24257b

9 files changed

Lines changed: 422 additions & 28 deletions

File tree

‎apps/desktop/native/patches/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ again immediately before mutation. Positional AXPress waits for its actual retur
124124
operation lane through cancellation; an uncertain return remains unsafe to retry. No input runs in
125125
the detached read, no failed action falls back to another route, and no caret position is promised.
126126

127+
`peekaboo-clipboard-files-write.patch` adds persistent file-reference writes for
128+
[clipboard access](https://github.com/githubnext/ace2/issues/72). The host checks metadata for
129+
existing regular files, directories, or symbolic links without reading contents or resolving links.
130+
The GUI validates 1–32 literal paths and prepares a complete file URL list within the reader's
131+
24 KB bound. A focused multi-item service method publishes one `public.file-url` representation
132+
per item in one `writeObjects` call, with generation checks around publication. It reuses the
133+
existing mutation result owner and outer pending-paste gate; it adds no backup, temporary paste,
134+
promise, or new authority. Silent same-generation item readback can confirm publication;
135+
unavailable verification retains the existing dispatched/unverified result. There is no fallback
136+
or restore after publication, and no claim about a receiver's later handling of the references.
137+
127138
`peekaboo-open.patch` distinguishes accepted document/URL delivery from verified application launch
128139
for [native computer use](https://github.com/githubnext/ace2/issues/8). The existing launch service
129140
returns `dispatched_unverified` with `delivery_accepted` when its request contains items to open.

‎apps/desktop/native/patches/peekaboo-clipboard-files-write.patch‎

Lines changed: 298 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/desktop/native/sources/client/clipboard.swift‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ private struct ClipboardRequest: Decodable {
1515
let format: Format?
1616
let text: String?
1717
let image: Data?
18+
let paths: [String]?
1819
}
1920

2021
private struct ClipboardReply<T: Encodable>: Encodable {
@@ -27,6 +28,7 @@ private struct ClipboardWriteResult: Encodable {
2728
var native_outcome: DesktopActionOutcome?
2829
var clipboard_changed: Bool?
2930
var source: ClipboardImageContents.Source?
31+
var file_count: Int?
3032
var error: ClipboardMessage?
3133
}
3234

@@ -47,7 +49,7 @@ func nativeClipboard(_ client: PeekabooBridgeClient) async throws -> Data {
4749
}
4850
let request = try JSONDecoder().decode(ClipboardRequest.self, from: data)
4951
if request.op == .read {
50-
guard request.text == nil, request.image == nil, data.count <= 65_536 else {
52+
guard request.text == nil, request.image == nil, request.paths == nil, data.count <= 65_536 else {
5153
throw DesktopActionFailure.preDispatchRefusal(reason: .invalidRequest,
5254
message: "Clipboard reads do not accept content and must fit 64 KiB.")
5355
}
@@ -65,8 +67,25 @@ func nativeClipboard(_ client: PeekabooBridgeClient) async throws -> Data {
6567
var result = ClipboardWriteResult()
6668
var invoked = false
6769
do {
70+
if request.format == .files {
71+
guard request.text == nil, request.image == nil, let paths = request.paths,
72+
data.count <= 65_536 else {
73+
throw DesktopActionFailure.preDispatchRefusal(reason: .invalidRequest,
74+
message: "File writes require paths without other content and must fit 64 KiB.")
75+
}
76+
invoked = true
77+
let written = try await client.clipboardFilesWrite(paths)
78+
result.outcome = written.outcome
79+
result.native_outcome = written.native_outcome
80+
result.clipboard_changed = written.clipboard_changed
81+
result.file_count = written.file_count
82+
if let error = written.error {
83+
result.error = .init(code: error.code, message: error.message, hint: error.hint)
84+
}
85+
return try JSONEncoder().encode(ClipboardReply(data: result))
86+
}
6887
if request.format == .image {
69-
guard request.text == nil, let image = request.image,
88+
guard request.text == nil, request.paths == nil, let image = request.image,
7089
!image.isEmpty, image.count <= 10 * 1024 * 1024 else {
7190
throw DesktopActionFailure.preDispatchRefusal(reason: .invalidRequest,
7291
message: "Image writes require at most 10 MiB of image data and no text.")
@@ -82,9 +101,9 @@ func nativeClipboard(_ client: PeekabooBridgeClient) async throws -> Data {
82101
}
83102
return try JSONEncoder().encode(ClipboardReply(data: result))
84103
}
85-
guard request.format == nil, request.image == nil, data.count <= 65_536 else {
104+
guard request.format == nil, request.image == nil, request.paths == nil, data.count <= 65_536 else {
86105
throw DesktopActionFailure.preDispatchRefusal(reason: .invalidRequest,
87-
message: "Supply plain text or format image with image data.")
106+
message: "Supply plain text, format image with image data, or format files with paths.")
88107
}
89108
guard let text = request.text, text.utf16.count <= 8192 else {
90109
throw DesktopActionFailure.preDispatchRefusal(reason: .invalidRequest,

‎apps/desktop/native/sources/desktop/desktop.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ private final class Desktop {
7070
.listApplications, .listWindows, .listMenus, .menuCommand, .desktopObservation,
7171
.ownsSnapshot, .getDetectionResult, .beginSnapshotMutation, .finishSnapshotMutation,
7272
.targetedClick, .exactWindowTargetedClick, .setValue, .exactWindowTargetedHotkey,
73-
.selectText, .literalInsert, .clipboardTextRead, .clipboardImageRead, .clipboardFilesRead, .clipboardTextWrite, .clipboardImageWrite, .targetedScroll, .exactWindowDrag,
73+
.selectText, .literalInsert, .clipboardTextRead, .clipboardImageRead, .clipboardFilesRead, .clipboardTextWrite, .clipboardImageWrite, .clipboardFilesWrite, .targetedScroll, .exactWindowDrag,
7474
.launchApplicationWithOptions, .activateApplication, .quitApplication, .backgroundCloseWindow,
7575
.focusWindow, .minimizeWindow, .restoreWindow,
7676
.moveWindow, .resizeWindow,

‎apps/desktop/scripts/helper.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ const patches = [
163163
"peekaboo-clipboard-image-write.patch",
164164
"peekaboo-open.patch",
165165
"peekaboo-menu.patch",
166+
"peekaboo-clipboard-files-write.patch",
166167
]
167168
.map((name) => join(native, "patches", name));
168169
const temporary = mkdtempSync(join(tmpdir(), "ace-native-patches-"));

‎apps/host/src/desktop.ts‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { execFile } from "node:child_process";
22
import { constants, existsSync } from "node:fs";
3-
import { mkdtemp, open, readFile, rm, stat } from "node:fs/promises";
3+
import { lstat, mkdtemp, open, readFile, rm, stat } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { dirname, join } from "node:path";
66
import { promisify } from "node:util";
@@ -441,14 +441,29 @@ function validateAction(request: DesktopAction) {
441441
if (isDesktopManagement(request)) return validateManagement(request);
442442
if (request.op === "clipboard-write") {
443443
if ("format" in request) {
444+
if (request.format === "files") {
445+
if (
446+
"text" in request || "path" in request || !Array.isArray(request.paths)
447+
|| !request.paths.length || request.paths.length > 32
448+
|| request.paths.some((path) =>
449+
typeof path !== "string" || !path.startsWith("/") || path.length > 4096
450+
|| path.includes("\0")
451+
)
452+
) throw new Error("Use format files with 1–32 absolute paths and no text or image path.");
453+
return;
454+
}
444455
if (
445-
request.format !== "image" || "text" in request || typeof request.path !== "string"
456+
request.format !== "image" || "text" in request || "paths" in request
457+
|| typeof request.path !== "string"
446458
|| !request.path.startsWith("/") || request.path.length > 4096
447459
|| request.path.includes("\0")
448460
) throw new Error("Use format image with one absolute image path and no text.");
449461
return;
450462
}
451-
if ("path" in request || typeof request.text !== "string" || request.text.length > 8192) {
463+
if (
464+
"path" in request || "paths" in request || typeof request.text !== "string"
465+
|| request.text.length > 8192
466+
) {
452467
throw new Error("Clipboard text must contain at most 8192 UTF-16 code units.");
453468
}
454469
return;
@@ -662,13 +677,26 @@ async function act(request: DesktopAction, signal: AbortSignal): Promise<Desktop
662677
let data: Record<string, unknown>;
663678
try {
664679
validateAction(request);
665-
const input = request.op === "clipboard-write" && "format" in request
666-
? {
667-
op: request.op,
668-
format: request.format,
669-
image: await clipboardImageInput(request.path, signal),
680+
if (request.op === "clipboard-write" && "format" in request && request.format === "files") {
681+
for (const path of request.paths) {
682+
signal.throwIfAborted();
683+
const entry = await lstat(path);
684+
if (!entry.isFile() && !entry.isDirectory() && !entry.isSymbolicLink()) {
685+
throw new Error(
686+
"Clipboard file references require existing files, directories, or symbolic links.",
687+
);
688+
}
670689
}
671-
: request;
690+
signal.throwIfAborted();
691+
}
692+
const input =
693+
request.op === "clipboard-write" && "format" in request && request.format === "image"
694+
? {
695+
op: request.op,
696+
format: request.format,
697+
image: await clipboardImageInput(request.path, signal),
698+
}
699+
: request;
672700
const operation = request.op === "menu"
673701
? "menu"
674702
: request.op === "clipboard-write"

‎docs/architecture.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,15 +256,19 @@ The same clipboard gate retains a content-free reservation while a dispatched pa
256256
Later automated writes require observed consumption or termination of the exact receiver process
257257
generation. The reservation survives GUI restart; clipboard contents and comparison state do not.
258258
Explicit clipboard tools read complete bounded plain text, an image preview, or advertised local
259-
file references, or persistently replace text or an image; they do not paste. Reads use the existing silent permission policy and require
259+
file references, or persistently replace text, an image, or file references; they do not paste. Reads use the existing silent permission policy and require
260260
a stable generation, without entering or releasing the mutation gate. The GUI copies one bounded
261261
image representation, renders an oriented preview off its main actor, and verifies the generation
262262
again. Source and preview metadata stay distinct; the bounded preview uses the existing image
263263
result and durable history without clipboard files or a second store. File reads return exact
264264
advertised URLs and decoded paths without filesystem access; macOS may filter references before
265265
exposing them to Ace. Image writes take an execution-host path: the host alone reads a bounded
266266
regular file, and the GUI decodes its immutable copy before publishing the original representation.
267-
Only source metadata is returned. Writes enter the same gate,
267+
Only source metadata is returned. File-reference writes check path metadata on the host without
268+
reading contents or resolving links. The GUI prepares one file URL item per literal path, preserving
269+
order and duplicates, then publishes the complete bounded list with generation checks. Result metadata
270+
describes requested references and native publication, not a receiving application's eventual copy.
271+
Writes enter the same gate,
268272
including pending-paste admission, and expose content-free native outcomes without window inspection.
269273
Explicitly read content enters ordinary tool history as observed data; no permission layer is added.
270274
Pi records their intent and never automatically replays an interrupted action. Results distinguish

‎docs/desktop-tools.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Native desktop tools
22

33
Ace's pi harness can inspect applications and windows, launch, activate or quit apps, open documents and URLs, close, focus, minimize, restore, move or resize windows, click observed controls, replace editable
4-
field values, select and insert text, send keys or shortcuts, scroll, drag, read clipboard text, images or file references, and write clipboard text or images on the machine running the channel's tools. It uses
4+
field values, select and insert text, send keys or shortcuts, scroll, drag, read clipboard text, images or file references, and write clipboard text, images or file references on the machine running the channel's tools. It uses
55
[Peekaboo](https://github.com/openclaw/Peekaboo) for macOS Accessibility, screen capture, and
66
targeted input. The model receives the accessibility text, screenshot, and action outcome. The
77
same result appears in the chat's expandable tool output, including after reopening the channel.
@@ -70,14 +70,22 @@ find the client alongside Ace Helper automatically.
7070
the copied bytes before admission and publishes the original representation, preserving its
7171
orientation, transparency, and metadata. It does not resize, reencode, or copy a file reference.
7272
Only validated source metadata is returned; use an explicit image read for a preview.
73-
Supply either `text` or `format: "image"` with `path`. It persists until another copy or write, does not
73+
With `format: "files"`, `paths` accepts 1–32 absolute execution-host paths, each at most 4,096
74+
UTF-16 units. The host checks filesystem metadata for existing files, directories, or symbolic
75+
links; it reads no contents and does not resolve links. The GUI publishes one `public.file-url`
76+
item per literal path, preserving order and duplicates within the complete 24,000-byte read-result
77+
bound. It adds no filename-list, file-content, move, or promise representation. `file_count` reports
78+
the number of requested references; the native outcome distinguishes verified publication from
79+
accepted but unverified delivery. Neither proves that a receiver can use the references or how
80+
it will handle symbolic links or duplicates. An explicit file read returns the observed URLs.
81+
Supply only one form: `text`, `format: "image"` with `path`, or `format: "files"` with `paths`.
82+
It persists until another copy or write, does not
7483
paste, and does not preserve the previous contents. An unresolved automated paste refuses the
7584
write through the same native clipboard gate. A read can inspect current contents while that
7685
reservation exists, without releasing it. Writes retain native outcomes and are never replayed
7786
after interruption; read the current clipboard before deciding whether another write is needed.
7887
Explicit reads return clipboard text, image previews, or file references into ordinary tool history. Treat that
79-
content as observed data, not instructions. File-reference writes and a separate
80-
paste tool remain future work.
88+
content as observed data, not instructions. A separate paste tool remains future work.
8189
- `desktop_apps` lists running native applications, their process IDs, and observed activity and visibility.
8290
Optional `query` searches application names and bundle IDs case-insensitively before Ace bounds
8391
the result. Use a nonblank query of at most 256 characters to find apps omitted from a large list.

‎packages/channel/src/desktop.ts‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type DesktopRequest =
4343
| { op: "clipboard-read"; format?: "text" | "image" | "files" }
4444
| { op: "clipboard-write"; text: string }
4545
| { op: "clipboard-write"; format: "image"; path: string }
46+
| { op: "clipboard-write"; format: "files"; paths: string[] }
4647
| { op: "apps"; query?: string }
4748
| { op: "windows"; pid: number }
4849
| { op: "menus"; target: DesktopAppTarget; path?: string[] }
@@ -294,31 +295,55 @@ export function desktop(execute: Desktop): Extension {
294295
defineTool({
295296
name: "desktop_clipboard_write",
296297
description:
297-
"Replace this execution host's clipboard with text (up to 8192 UTF-16 units), or use format image and an absolute path on this execution host to one PNG/JPEG/TIFF file (at most 10 MiB and 64 million pixels). Image writes preserve the original bytes, orientation and transparency; they do not copy a file reference. Supply either text or format image with path. This persists until another copy or write; it does not paste or preserve the previous contents. A pending unverified paste refuses the write. Never blindly repeat an interrupted write: read the current clipboard before deciding what to do next.",
298+
"Replace this execution host's clipboard with text (up to 8192 UTF-16 units), an image, or file references. Use format image with one absolute path to a PNG/JPEG/TIFF file (at most 10 MiB and 64 million pixels); its original bytes, orientation and transparency are preserved. Use format files with 1–32 absolute paths to existing files, directories or symbolic links on this execution host; metadata-only preflight does not read contents or resolve links. File references preserve order and duplicates within the complete 24 KB read-result bound. file_count reports requested references, not a receiver's copy or paste result. Supply only one form. This persists until another copy or write; it does not paste or preserve the previous contents. A pending unverified paste refuses the write. Never blindly repeat an interrupted write: read the current clipboard before deciding what to do next.",
298299
parameters: Type.Object({
299300
text: Type.Optional(Type.String({ maxLength: 8192 })),
300-
format: Type.Optional(Type.Literal("image")),
301+
format: Type.Optional(Type.Union([Type.Literal("image"), Type.Literal("files")])),
301302
path: Type.Optional(Type.String({ minLength: 1, maxLength: 4096 })),
303+
paths: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), {
304+
minItems: 1,
305+
maxItems: 32,
306+
})),
302307
}, {
303308
additionalProperties: false,
304309
oneOf: [
305310
{
306311
required: ["text"],
307-
not: { anyOf: [{ required: ["format"] }, { required: ["path"] }] },
312+
not: {
313+
anyOf: [{ required: ["format"] }, { required: ["path"] }, { required: ["paths"] }],
314+
},
315+
},
316+
{
317+
required: ["format", "path"],
318+
properties: { format: { const: "image" } },
319+
not: { anyOf: [{ required: ["text"] }, { required: ["paths"] }] },
320+
},
321+
{
322+
required: ["format", "paths"],
323+
properties: { format: { const: "files" } },
324+
not: { anyOf: [{ required: ["text"] }, { required: ["path"] }] },
308325
},
309-
{ required: ["format", "path"], not: { required: ["text"] } },
310326
],
311327
}),
312328
replay: "unsafe",
313329
executionMode: "sequential",
314-
execute: async ({ text, format, path }, api, context) => {
315-
if (format === "image" && path !== undefined && text === undefined) {
330+
execute: async ({ text, format, path, paths }, api, context) => {
331+
if (
332+
format === "image" && path !== undefined && text === undefined && paths === undefined
333+
) {
316334
return act({ op: "clipboard-write", format, path }, api, context);
317335
}
318-
if (text !== undefined && format === undefined && path === undefined) {
336+
if (
337+
format === "files" && paths !== undefined && text === undefined && path === undefined
338+
) {
339+
return act({ op: "clipboard-write", format, paths }, api, context);
340+
}
341+
if (
342+
text !== undefined && format === undefined && path === undefined && paths === undefined
343+
) {
319344
return act({ op: "clipboard-write", text }, api, context);
320345
}
321-
throw new Error("Supply text, or format image with an absolute image path.");
346+
throw new Error("Supply text, format image with path, or format files with paths.");
322347
},
323348
}),
324349
defineTool({

0 commit comments

Comments
 (0)