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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/migrating-to-1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ that had to fall back to the built-in patterns. `--strict` is documented as
"enable every applicable policy-failure check" and did not read that list. It
does now, and exits 3.

A clipboard that could not take what was asked for is a different case: the
machine failed rather than a check, so `--strict` exits 1. That includes a file
reference the platform could only copy as its path in text.

### `toJSON()` no longer includes the stack

`error.toJSON()` — which is also what `JSON.stringify(error)` calls — returns
Expand Down
4 changes: 4 additions & 0 deletions docs/technical/behavior-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,10 @@ kilobytes of inline context.
- `-r` / `--as-reference` is accepted and does nothing, since it now selects the
default.
- `-o`, `--display`, and `--stream` are unaffected and still take precedence.
- Where the platform cannot put a file on the clipboard, the path is copied as
text and the run says "Path copied as text, not as a file reference", with the
reason; `--strict` makes that exit 1. On macOS the reference is written to the
pasteboard directly, so it needs no Automation permission for Finder.

**Folder-profile discovery is no longer tied to `-r`.**
Auto-discovery of a `.copytree.yml` used to happen only when `-r` was passed.
Expand Down
14 changes: 13 additions & 1 deletion src/commands/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,19 @@ async function deliverOutput(result, request, root, reporter) {
if (destination.type === 'reference') {
const tempFile = await writeReferenceFile(output, root, request.content.format);
try {
await (await clipboard()).copyFileReference(tempFile);
const copied = await (await clipboard()).copyFileReference(tempFile);
if (copied?.asText) {
// The path is on the clipboard, which an agent can still open, but a
// browser will not attach it, so this is not the reference asked for.
return {
requested: 'reference',
actual: 'path',
status: 'warning',
path: tempFile,
fallbackUsed: true,
cause: copied.cause,
};
}
return {
requested: 'reference',
actual: 'reference',
Expand Down
11 changes: 9 additions & 2 deletions src/commands/ignore.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,15 @@ async function ignoreContext(request, context) {
const file = await writeReferenceFile(text, root, format === 'json' ? 'json' : 'markdown');
const { default: clipboard } = await import('../utils/clipboard.js');
try {
await clipboard.copyFileReference(file);
feedback.write(`Ignore context written to ${file} and copied as a file reference`);
const copied = await clipboard.copyFileReference(file);
if (copied?.asText) {
feedback.write(
`Ignore context written to ${file}; its path was copied as text (${copied.cause})`,
{ level: 'warn' },
);
} else {
feedback.write(`Ignore context written to ${file} and copied as a file reference`);
}
} catch (error) {
feedback.write(
`Ignore context written to ${file} (clipboard unavailable: ${error.message})`,
Expand Down
18 changes: 15 additions & 3 deletions src/ui/feedback/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,14 +222,25 @@ export function classifyWarnings(result = {}, extra = {}) {
if (extra.delivery?.fallbackUsed) {
warnings.push({
code: WARNING_CODES.CLIPBOARD_FALLBACK,
message: `Clipboard unavailable — output saved to ${extra.delivery.path}`,
message: fallbackHeadline(extra.delivery),
data: { path: extra.delivery.path, cause: extra.delivery.cause },
});
}

return warnings;
}

/**
* What a clipboard fallback left the reader holding.
*
* @param {Object} [delivery] - Delivery result with `fallbackUsed`
* @returns {string} Headline without a glyph
*/
function fallbackHeadline(delivery) {
if (delivery?.actual === 'path') return 'Path copied as text, not as a file reference';
return `Clipboard unavailable — output saved to ${delivery?.path ?? 'a temporary file'}`;
}

/**
* Things worth mentioning that are not problems.
*
Expand Down Expand Up @@ -314,10 +325,11 @@ export function buildCompletionModel({
return {
event: FEEDBACK_EVENTS.RUN_COMPLETE,
status: 'warning',
headline: `Clipboard unavailable — output saved to ${delivery?.path ?? 'a temporary file'}`,
headline: fallbackHeadline(delivery),
metrics: buildMetrics(stats, verbose),
warnings: warnings.filter((w) => w !== fallback),
notes: notices,
// The headline says what happened; the cause is what the reader can fix.
notes: delivery?.actual === 'path' && delivery.cause ? [delivery.cause, ...notices] : notices,
details: { path: delivery?.path, destination: delivery?.actual },
stats,
};
Expand Down
107 changes: 76 additions & 31 deletions src/utils/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { logger } from './logger.js';
/**
* How long to wait for the platform's clipboard helper.
*
* Short on purpose: a wedged Finder or a headless session must not hang a copy
* Short on purpose: a wedged helper or a headless session must not hang a copy
* that has otherwise finished. But two seconds is a guess about someone else's
* machine, and it is wrong on a loaded CI runner, a remote desktop session, or
* a VM with cold page cache — where the helper is killed mid-write and the run
* silently falls back to copying text.
* falls back to copying the path as text.
*
* `COPYTREE_CLIPBOARD_TIMEOUT_MS` raises it for those environments.
*
Expand All @@ -22,6 +22,27 @@ function clipboardTimeout() {
return Number.isInteger(configured) && configured > 0 ? configured : 2000;
}

/**
* Put a file URL on the general pasteboard from osascript's own process.
*
* `tell app "Finder" to set the clipboard` is an Apple Event, and macOS lets it
* through only when the terminal hosting this process declares Automation
* access and the user has approved it. A terminal that does not declare it is
* refused with -1743 and no prompt, so the copy degraded to text there. Writing
* NSPasteboard through the AppKit bridge sends no Apple Event and needs no
* consent; `writeObjects` with an NSURL publishes `public.file-url` and the
* legacy `NSFilenamesPboardType`, which is what browsers read for a file paste.
* The path arrives as an argument, so it needs no escaping.
*/
const MACOS_PASTEBOARD_SCRIPT = `ObjC.import('AppKit');
function run(argv) {
const pasteboard = $.NSPasteboard.generalPasteboard;
pasteboard.clearContents;
if (!pasteboard.writeObjects($([$.NSURL.fileURLWithPath(argv[0])]))) {
throw new Error('the pasteboard did not accept the file URL');
}
}`;

/**
* Enhanced clipboard utilities with file reference support
*/
Expand All @@ -37,10 +58,32 @@ class Clipboard {
return await clipboardy.write(text);
}

/**
* Copy the path as text in place of a file reference.
*
* An agent can still open a pasted path, so this is a degraded success rather
* than a failure. It is reported as one, because a browser will not attach a
* path and "File reference copied" would send the reader to paste it there.
*
* @param {string} text - Path or file URI to copy
* @param {string} cause - Why no file reference could be copied
* @returns {Promise<{asText: true, cause: string}>} Outcome
* @private
*/
static async _copyAsText(text, cause) {
await this.copyText(text);
return { asText: true, cause };
}

/**
* Copy file reference to clipboard (cross-platform)
*
* Falls back to the path as text when the platform cannot take a file, and
* says so in the result; rejects only when the text copy fails too.
*
* @param {string} filePath - The file path to copy as reference
* @returns {Promise<void>}
* @returns {Promise<{asText: boolean, cause?: string}>} Whether a file reference
* was copied, or only its path as text and why
*/
static async copyFileReference(filePath) {
if (process.platform === 'win32') {
Expand All @@ -65,16 +108,15 @@ class Clipboard {
'Failed to copy file reference using PowerShell, falling back to text:',
error.message,
);
return this.copyText(filePath);
return this._copyAsText(filePath, error.message);
}
} else if (process.platform === 'linux') {
try {
const fileUri = url.pathToFileURL(filePath).toString();
const method = Clipboard._detectLinuxClipboardMethod(fileUri);

if (!method) {
// Neither xclip nor wl-copy available; fall back to plain-text URI
return this.copyText(fileUri);
return this._copyAsText(fileUri, 'no display server detected');
}

const result = spawnSync(method.tool, method.args, {
Expand All @@ -88,52 +130,55 @@ class Clipboard {
`Failed to copy file reference using ${method.tool}, falling back to text:`,
result.error?.message || `exit code ${result.status}`,
);
return this.copyText(fileUri);
return this._copyAsText(
fileUri,
result.error?.message || `${method.tool} exited with code ${result.status}`,
);
}
} catch (error) {
logger.debug(
'Failed to copy file reference on Linux, falling back to text:',
error.message,
);
const fileUri = url.pathToFileURL(filePath).toString();
return this.copyText(fileUri);
return this._copyAsText(fileUri, error.message);
}
} else if (process.platform === 'darwin') {
try {
// Use AppleScript to copy file reference. spawnSync passes the script as a
// direct argument (no shell), so single quotes in filePath are safe.
const escapedFilePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const script = `set aFile to POSIX file "${escapedFilePath}"
tell app "Finder" to set the clipboard to aFile`;

const result = spawnSync('osascript', ['-e', script], {
encoding: 'utf8',
stdio: 'pipe',
timeout: clipboardTimeout(),
});
const result = spawnSync(
'osascript',
// `--` so a relative path starting with `-` is not read as an option.
['-l', 'JavaScript', '-e', MACOS_PASTEBOARD_SCRIPT, '--', filePath],
{
encoding: 'utf8',
stdio: 'pipe',
timeout: clipboardTimeout(),
},
);

// `spawnSync` does not throw when the child exits non-zero, so the
// result had to be inspected and was not. A headless session, a denied
// automation permission or a Finder that will not answer all left the
// clipboard untouched while the run reported "File reference copied" —
// the user pastes, gets whatever was there before, and has no reason to
// suspect this command. The Linux branch above already checks both.
// result had to be inspected and was not. A headless session or a
// pasteboard that would not take the URL left no file on the clipboard
// while the run reported "File reference copied" — the user pastes,
// gets something else, and has no reason to suspect this command.
if (result?.error || (result && result.status !== 0)) {
// The first line only: the reason becomes a note under the headline.
const reason =
result.error?.message ||
(result.stderr || '').trim() ||
`osascript exited with code ${result.status}`;
(result.stderr || '').trim().split('\n')[0] ||
(result.signal
? `osascript was stopped by ${result.signal}`
: `osascript exited with code ${result.status}`);
throw new Error(reason);
}
} catch (error) {
logger.debug('Failed to copy file reference, falling back to text:', error.message);
// Fallback to copying path as text
return this.copyText(filePath);
return this._copyAsText(filePath, error.message);
}
} else {
// Fallback for other OSes
return this.copyText(filePath);
return this._copyAsText(filePath, `${process.platform} has no file-reference mechanism`);
}
return { asText: false };
}

/**
Expand All @@ -151,7 +196,7 @@ tell app "Finder" to set the clipboard to aFile`;
return {
supported: true,
mechanism: 'osascript',
detail: 'Finder clipboard via AppleScript',
detail: 'NSPasteboard via osascript',
};
}
if (process.platform === 'win32') {
Expand Down
52 changes: 46 additions & 6 deletions tests/e2e/file-reference.os.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,12 @@ describe('OS-specific file reference copy', () => {
expect(existsSync(logFile)).toBe(true);

const logged = readFileSync(logFile, 'utf8');
expect(logged).toContain('-e');
expect(logged).toContain('set aFile to POSIX file');
expect(logged).toContain('tell app "Finder" to set the clipboard to aFile');
expect(logged).toContain('JavaScript');
expect(logged).toContain('NSPasteboard');
expect(logged).not.toContain('Finder');

const match = logged.match(/POSIX file "([^"]+\.xml)"/);
expect(match).not.toBeNull();
const referencedPath = match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
// The path is the script's last argument, one per logged line.
const referencedPath = logged.trimEnd().split('\n').at(-1);
// `<temp>/copytree/<project-slug>/<UTC-timestamp>-<short-hash>.<ext>`
expect(referencedPath).toMatch(/\/copytree\/simple-project\//);
expect(path.basename(referencedPath)).toMatch(/^\d{4}-\d{2}-\d{2}T[\d-]+Z-[0-9a-f]{8}\.xml$/);
Expand Down Expand Up @@ -166,4 +165,45 @@ describe('OS-specific file reference copy', () => {
// freshly-written script is genuinely slow under some sandboxes and
// security tooling, so the headroom is not theoretical.
}, 60000);

// A file reference that degrades to the path as text used to report "File
// reference copied", so `--strict` passed and the reader pasted a path into a
// browser expecting an attachment.
(process.platform === 'darwin' ? test : test.skip)(
'reports a reference that fell back to text, and fails it under --strict',
async () => {
tempDir = mkdtempSync(path.join(os.tmpdir(), 'copytree-file-ref-text-'));
const binDir = path.join(tempDir, 'bin');
const logFile = path.join(tempDir, 'pbcopy.log');
mkdirSync(binDir, { recursive: true });
writeFileSync(
path.join(binDir, 'osascript'),
'#!/bin/sh\necho "execution error: the pasteboard is unavailable" >&2\nexit 1\n',
{ mode: 0o755 },
);
writeFileSync(path.join(binDir, 'pbcopy'), '#!/bin/sh\ncat > "$COPYTREE_TEST_LOG"\n', {
mode: 0o755,
});

const { code, stderr } = await runCli(
[PROJECT, '--as-reference', '--format', 'xml', '--strict'],
{
env: {
COPYTREE_TEST_LOG: logFile,
PATH: prependPath(binDir, process.env.PATH),
TMPDIR: tempDir,
TMP: tempDir,
TEMP: tempDir,
COPYTREE_CLIPBOARD_TIMEOUT_MS: '30000',
},
},
);

expect(code).toBe(1);
expect(stderr).toContain('Delivery fell back to path');
expect(stderr).toContain('the pasteboard is unavailable');
expectReferencePath(readFileSync(logFile, 'utf8').trim());
},
60000,
);
});
Loading
Loading