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: 2 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Across all of the above, no traffic is routed through servers operated by the Pa

A few features hand short pieces of data to your operating system itself:

- **Clipboard pasting and drag-drop** — when you paste into or drop content onto an agent prompt, Parallel Code reads the OS clipboard or the drop payload. If it contains a file reference (`public.file-url`, `text/uri-list`, or GNOME's `x-special/gnome-copied-files`), the file path is passed to the agent. If it contains an image, the image bytes are written to your OS temp directory so the agent can reference the image as a file path: clipboard pastes overwrite a single file at `$TMPDIR/parallel-code-clipboard.png`, and drops are written to `$TMPDIR/parallel-code-drop-<timestamp>-<random>[-<original-filename>]` — the original drop filename, if any, is sanitised and appended. These temp files are not deleted by Parallel Code; they remain in your temp directory until your OS cleans it (typically on reboot or via periodic cleanup). Plain text is inserted into the prompt. As with any prompt, once you submit it (or the app detects it as prompt input from the terminal), it can be stored locally as task metadata (the task's `lastPrompt`) in `state.json`.
- **Clipboard pasting and drag-drop** — when you paste into or drop content onto an agent prompt, Parallel Code reads the OS clipboard or the drop payload. If it contains a file reference (`public.file-url`, `text/uri-list`, or GNOME's `x-special/gnome-copied-files`), the file path is passed to the agent. If it contains an image, the image bytes are written to a unique `$TMPDIR/parallel-code-drop-<timestamp>-<random>[-<original-filename>]` file so the agent can reference the image as a file path; clipboard images use `clipboard.png` as the original filename. These temp files are not deleted by Parallel Code, so repeated clipboard images and drops accumulate until your OS cleans its temp directory (typically on reboot or via periodic cleanup). Plain text is inserted into the prompt. As with any prompt, once you submit it (or the app detects it as prompt input from the terminal), it can be stored locally as task metadata (the task's `lastPrompt`) in `state.json`.
- **Clipboard writes** — when you click "Copy" in certain UI controls (for example the Connect Phone dialog), Parallel Code writes to the OS clipboard. The Remote Access URL it copies contains the **session bearer token** as a query parameter, so anything that reads or syncs your clipboard (clipboard managers, screen-sharing tools, cross-device clipboard sync) will see that token until you copy something else or restart Remote Access. Other clipboard writes (terminal selection, theme prompts, task steps) carry only the content you asked to copy.
- **Native notifications** — when a task completes, needs attention, or when GitHub PR checks succeed or fail, Parallel Code uses the OS notification API. The notification's title and body (typically the task name and a short status; for failed PR checks, the failed check names) are visible to the operating system and any surfaces that mirror notifications (Notification Center, etc.).
- **Microphone entitlement (macOS)** — the packaged macOS app declares `NSMicrophoneUsageDescription` and the corresponding hardened-runtime entitlement, so the OS will permit microphone access if a feature requests it. The current build contains no active microphone capture code; granting the permission has no effect until a microphone-using feature ships. Note that the renderer permission handler in `electron/main.ts` auto-approves audio media requests — so if microphone-using code is added later, the only consent gate will be the OS-level microphone prompt.
Expand All @@ -101,7 +101,7 @@ Inside the git repositories you point the app at, Parallel Code may also write:
Other places Parallel Code can write state:

- `~/.parallel-code/agent-auth/<agent>/` — only if you enable "Share agent auth across Linux containers" (see Docker task isolation); accumulates agent credentials across sessions.
- OS temp directory (`$TMPDIR`) — `parallel-code-clipboard.png` and the `parallel-code-drop-*` files from clipboard/drop image handling (not deleted by Parallel Code), plus the host-mode coordinator and sub-task MCP configs and `.parallel-code-atomic-<uuid>.tmp` files described under Sub-task coordinator above.
- OS temp directory (`$TMPDIR`) — `parallel-code-drop-*` files from clipboard/drop image handling (not deleted by Parallel Code), plus the host-mode coordinator and sub-task MCP configs and `.parallel-code-atomic-<uuid>.tmp` files described under Sub-task coordinator above.

Persistence the app itself does not control but that mirrors content the app produced:

Expand Down
205 changes: 202 additions & 3 deletions electron/ipc/ask-code-minimax.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';

// Mock fetch globally
const mockFetch = vi.fn();
Expand All @@ -7,7 +11,6 @@ vi.stubGlobal('fetch', mockFetch);
import {
askAboutCodeMinimax,
cancelAskAboutCodeMinimax,
MINIMAX_MODEL,
setMinimaxApiKey,
} from './ask-code-minimax.js';

Expand Down Expand Up @@ -180,7 +183,7 @@ describe('askAboutCodeMinimax', () => {
const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as {
model: string;
};
expect(body.model).toBe(MINIMAX_MODEL);
expect(body.model).toBe('MiniMax-M2.7');
});

it('uses temperature in MiniMax allowed range (0, 1]', async () => {
Expand Down Expand Up @@ -261,6 +264,202 @@ describe('askAboutCodeMinimax', () => {
});
});

describe('minimax image input', () => {
const pngPath = path.join(os.tmpdir(), 'parallel-code-ask-code-test.png');
const jpegWithPngExtensionPath = path.join(os.tmpdir(), 'parallel-code-ask-code-jpeg-test.png');
const oversizedPath = path.join(os.tmpdir(), 'parallel-code-ask-code-oversized.png');
const fifoPath = path.join(os.tmpdir(), 'parallel-code-ask-code-fifo.png');
const pngBytes = Buffer.from('89504e470d0a1a0a', 'hex');
const jpegBytes = Buffer.from('ffd8ffe000104a464946', 'hex');

beforeAll(() => {
fs.writeFileSync(pngPath, pngBytes);
fs.writeFileSync(jpegWithPngExtensionPath, jpegBytes);
fs.writeFileSync(oversizedPath, pngBytes);
fs.truncateSync(oversizedPath, 10 * 1024 * 1024 + 1);
// A FIFO stats as size 0, so it slips past the size caps; opening it for
// read blocks until a writer appears, which nothing here provides.
fs.rmSync(fifoPath, { force: true });
execFileSync('mkfifo', [fifoPath]);
});

afterAll(() => {
fs.rmSync(pngPath, { force: true });
fs.rmSync(jpegWithPngExtensionPath, { force: true });
fs.rmSync(oversizedPath, { force: true });
fs.rmSync(fifoPath, { force: true });
});

beforeEach(() => {
vi.clearAllMocks();
setMinimaxApiKey('test-key');
});

function requestBody() {
return JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) as {
model: string;
messages: Array<{
role: string;
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
}>;
};
}

it('keeps the user content a plain string when no image is attached', async () => {
const { win, messages } = makeMockWin();

mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n'));

askAboutCodeMinimax(win, {
requestId: 'img-none',
channelId: 'ch-img-none',
prompt: 'Explain this',
});

await waitForDone(messages);

const body = requestBody();
expect(body.model).toBe('MiniMax-M2.7');
expect(body.messages.find((m) => m.role === 'user')?.content).toBe('Explain this');
});

it('sends attached images as content parts to an image-capable model', async () => {
const { win, messages } = makeMockWin();

mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n'));

askAboutCodeMinimax(win, {
requestId: 'img-one',
channelId: 'ch-img-one',
prompt: 'What does this screenshot show?',
imagePaths: [pngPath],
});

await waitForDone(messages);

const body = requestBody();
expect(body.model).toBe('MiniMax-M3');

const userContent = body.messages.find((m) => m.role === 'user')?.content;
expect(Array.isArray(userContent)).toBe(true);
const parts = userContent as Array<{
type: string;
text?: string;
image_url?: { url: string };
}>;
expect(parts[0]).toEqual({ type: 'text', text: 'What does this screenshot show?' });
expect(parts[1].type).toBe('image_url');
expect(parts[1].image_url?.url).toBe(`data:image/png;base64,${pngBytes.toString('base64')}`);

// The system message stays a plain string
expect(typeof body.messages.find((m) => m.role === 'system')?.content).toBe('string');
});

it('uses the file signature for the image MIME type', async () => {
const { win, messages } = makeMockWin();

mockFetch.mockResolvedValueOnce(makeStreamResponse('data: [DONE]\n\n'));

askAboutCodeMinimax(win, {
requestId: 'img-signature',
channelId: 'ch-img-signature',
prompt: 'Inspect this image',
imagePaths: [jpegWithPngExtensionPath],
});

await waitForDone(messages);

const content = requestBody().messages.find((message) => message.role === 'user')?.content;
const parts = content as Array<{ image_url?: { url: string } }>;
expect(parts[1].image_url?.url).toBe(`data:image/jpeg;base64,${jpegBytes.toString('base64')}`);
});

it('rejects image types the chat API cannot accept', () => {
const { win } = makeMockWin();

expect(() =>
askAboutCodeMinimax(win, {
requestId: 'img-bad-type',
channelId: 'ch-img-bad-type',
prompt: 'Test',
imagePaths: [path.join(os.tmpdir(), 'notes.txt')],
}),
).toThrow(/Unsupported image type/);
expect(mockFetch).not.toHaveBeenCalled();
});

it('rejects more images than a single question allows', () => {
const { win } = makeMockWin();

expect(() =>
askAboutCodeMinimax(win, {
requestId: 'img-too-many',
channelId: 'ch-img-too-many',
prompt: 'Test',
imagePaths: [pngPath, pngPath, pngPath, pngPath, pngPath],
}),
).toThrow(/Too many images/);
expect(mockFetch).not.toHaveBeenCalled();
});

it('reports an unreadable image as an error instead of sending a request', async () => {
const { win, messages } = makeMockWin();

askAboutCodeMinimax(win, {
requestId: 'img-missing',
channelId: 'ch-img-missing',
prompt: 'Test',
imagePaths: [path.join(os.tmpdir(), 'parallel-code-does-not-exist.png')],
});

await waitForDone(messages);

const errors = messages.filter((m) => (m as Record<string, unknown>).type === 'error');
expect(errors).toHaveLength(1);
expect(mockFetch).not.toHaveBeenCalled();
});

it('rejects an oversized image before reading it', async () => {
const { win, messages } = makeMockWin();

askAboutCodeMinimax(win, {
requestId: 'img-oversized',
channelId: 'ch-img-oversized',
prompt: 'Test',
imagePaths: [oversizedPath],
});

await waitForDone(messages);

const errors = messages.filter(
(message) => (message as Record<string, unknown>).type === 'error',
);
expect(errors).toHaveLength(1);
expect((errors[0] as Record<string, unknown>).text).toMatch(/Image too large/);
expect(mockFetch).not.toHaveBeenCalled();
});

it('rejects a path that is not a regular file instead of blocking on it', async () => {
const { win, messages } = makeMockWin();

askAboutCodeMinimax(win, {
requestId: 'img-fifo',
channelId: 'ch-img-fifo',
prompt: 'Test',
imagePaths: [fifoPath],
});

await waitForDone(messages);

const errors = messages.filter(
(message) => (message as Record<string, unknown>).type === 'error',
);
expect(errors).toHaveLength(1);
expect((errors[0] as Record<string, unknown>).text).toMatch(/Not a regular file/);
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('cancelAskAboutCodeMinimax', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
Loading
Loading