Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/desktop/screenshots/mockBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ const bridge: AiOptBridge = {
stopTask: refused,
removeTask: refused,
taskOutput: async () => ({ text: '' }),
taskChanges: async () => ({ since: 'launch' as const, files: [], truncated: false }),
runAll: refused,
approveTasks: refused,
runPlan: refused,
Expand Down
146 changes: 146 additions & 0 deletions apps/desktop/src/main/workbench/__tests__/taskChanges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { execFile, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
GIT_SAFE_CONFIG,
isCommitId,
parseNameStatusZ,
parseNumstatZ,
readHeadCommit,
readTaskChanges,
splitPatch,
type GitExec,
} from '../taskChanges';

describe('task changes parsing', () => {
it('parses numstat -z including renames and binaries', () => {
expect(parseNumstatZ('3\t1\ta.ts\0-\t-\timg.png\0' + '2\t0\t\0old name.ts\0new name.ts\0')).toEqual([
{ path: 'a.ts', oldPath: null, additions: 3, deletions: 1 },
{ path: 'img.png', oldPath: null, additions: null, deletions: null },
{ path: 'new name.ts', oldPath: 'old name.ts', additions: 2, deletions: 0 },
]);
});

it('parses name-status -z', () => {
const map = parseNameStatusZ('M\0a.ts\0A\0b.ts\0D\0c.ts\0R087\0old.ts\0new.ts\0');
expect([...map]).toEqual([
['a.ts', 'modified'],
['b.ts', 'added'],
['c.ts', 'deleted'],
['new.ts', 'renamed'],
]);
});

it('splits only at line-start diff headers', () => {
const text = 'diff --git a/x b/x\n+say diff --git inline\ndiff --git a/y b/y\n-1\n';
expect(splitPatch(text)).toEqual(['diff --git a/x b/x\n+say diff --git inline\n', 'diff --git a/y b/y\n-1\n']);
expect(splitPatch('')).toEqual([]);
});

it('accepts only full commit ids', () => {
expect(isCommitId('a'.repeat(40))).toBe(true);
expect(isCommitId('HEAD')).toBe(false);
expect(isCommitId('--output=/tmp/x')).toBe(false);
expect(isCommitId('a'.repeat(39))).toBe(false);
});
});

function gitAvailable(): boolean {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}

const realExec: GitExec = (args) =>
new Promise((resolve, reject) => {
execFile(
'git',
[...args],
{
encoding: 'utf8',
env: {
PATH: process.env.PATH ?? '',
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: os.devNull,
GIT_AUTHOR_NAME: 't',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 't',
GIT_COMMITTER_EMAIL: 't@example.com',
},
},
(err, stdout) => {
const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 0;
if (err && typeof (err as { code?: unknown }).code !== 'number') reject(err);
else resolve({ code, stdout });
},
);
});

describe.skipIf(!gitAvailable())('readTaskChanges (real git)', () => {
let dir = '';
const run = async (...args: string[]) => {
const out = await realExec(['-C', dir, ...args]);
if (out.code !== 0) throw new Error(`git ${args.join(' ')} failed`);
};

beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aiopt-taskchanges-'));
await run('init', '-q');
fs.writeFileSync(path.join(dir, 'a.txt'), 'one\ntwo\n');
fs.writeFileSync(path.join(dir, 'gone.txt'), 'bye\n');
fs.mkdirSync(path.join(dir, 'sub'));
fs.writeFileSync(path.join(dir, 'sub', 'b.txt'), 'b\n');
await run('add', '.');
await run('commit', '-q', '-m', 'init');
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

it('diffs against the launch commit: commits since, uncommitted edits and untracked files', async () => {
const base = await readHeadCommit(realExec, dir);
expect(base && isCommitId(base)).toBe(true);
fs.writeFileSync(path.join(dir, 'a.txt'), 'one\n2\nthree\n');
await run('commit', '-q', '-am', 'worker commit');
fs.rmSync(path.join(dir, 'gone.txt'));
fs.writeFileSync(path.join(dir, 'new file.txt'), 'x\ny\n');

const out = await readTaskChanges(realExec, dir, base);
expect(out.since).toBe('launch');
expect(out.truncated).toBe(false);
const byPath = new Map(out.files.map((f) => [f.path, f]));
expect(byPath.get('a.txt')).toMatchObject({ status: 'modified', additions: 2, deletions: 1 });
expect(byPath.get('a.txt')?.patch).toContain('+three');
expect(byPath.get('gone.txt')).toMatchObject({ status: 'deleted', deletions: 1 });
expect(byPath.get('new file.txt')).toMatchObject({ status: 'untracked', additions: 2, deletions: 0 });
expect(byPath.get('new file.txt')?.patch).toContain('+y');
});

it('falls back to HEAD for an unknown base and limits a subfolder to itself', async () => {
fs.writeFileSync(path.join(dir, 'a.txt'), 'changed\n');
fs.writeFileSync(path.join(dir, 'sub', 'b.txt'), 'b2\n');
const sub = path.join(dir, 'sub');
const out = await readTaskChanges(realExec, sub, 'f'.repeat(40));
expect(out.since).toBe('head');
expect(out.files.map((f) => f.path)).toEqual(['b.txt']);
});

it('never runs an external diff driver configured in the repo', async () => {
const marker = path.join(dir, 'ran');
await run('config', 'diff.external', `sh -c 'touch ${marker}'`);
fs.writeFileSync(path.join(dir, 'a.txt'), 'changed\n');
const out = await readTaskChanges(realExec, dir, null);
expect(out.files[0]?.patch).toContain('+changed');
expect(fs.existsSync(marker)).toBe(false);
});

it('puts the hardening config before the subcommand', () => {
expect(GIT_SAFE_CONFIG).toContain('core.fsmonitor=false');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function setup(picked: string | null = '/work/app') {
stopTask: vi.fn(async () => {}),
removeTask: vi.fn(async () => {}),
taskOutput: vi.fn(async () => 'out'),
taskChanges: vi.fn(async () => ({ since: 'launch' as const, files: [], truncated: false })),
runAll: vi.fn(() => 0),
approveTasks: vi.fn(),
runPlan: vi.fn(),
Expand Down Expand Up @@ -80,7 +81,7 @@ const CHANNELS = Object.entries(IPC_CHANNELS).filter(([key]) => key.startsWith('
describe('workbench ipc', () => {
it('registers every workbench channel and asserts the trusted sender first', async () => {
const { reg, manager, pickFolder, revealItem, folderOps } = setup();
expect(CHANNELS).toHaveLength(37);
expect(CHANNELS).toHaveLength(38);
for (const [, channel] of CHANNELS) {
expect(await codeOf(reg.invoke(channel, { taskId: 'id1', folderId: 'id1', text: 'x' }, untrusted))).toBe(
'PERMISSION_DENIED',
Expand Down Expand Up @@ -195,6 +196,9 @@ describe('workbench ipc', () => {
const { reg, manager } = setup();
expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchTaskLaunch, { taskId: '../x' }, trusted))).toBe('INVALID_PARAMS');
expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchTaskStop, { taskId: 7 }, trusted))).toBe('INVALID_PARAMS');
expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchTaskChanges, { taskId: '/etc' }, trusted))).toBe(
'INVALID_PARAMS',
);
expect(await codeOf(reg.invoke(IPC_CHANNELS.workbenchFolderRemove, { folderId: 'A B' }, trusted))).toBe(
'INVALID_PARAMS',
);
Expand Down Expand Up @@ -253,6 +257,9 @@ describe('workbench ipc', () => {
await reg.invoke(IPC_CHANNELS.workbenchTaskMessage, { taskId: 'id1', text: 'go on' }, trusted);
expect(manager.messageTask).toHaveBeenCalledWith('id1', 'go on');
expect(await reg.invoke(IPC_CHANNELS.workbenchTaskOutput, { taskId: 'id1' }, trusted)).toEqual({ text: 'out' });
// Only the task id reaches the manager: a path in the payload is ignored.
await reg.invoke(IPC_CHANNELS.workbenchTaskChanges, { taskId: 'id1', path: '/etc' }, trusted);
expect(manager.taskChanges).toHaveBeenCalledWith('id1');

expect(await reg.invoke(IPC_CHANNELS.workbenchTaskRunAll, undefined, trusted)).toBe(SNAP);
expect(manager.runAll).toHaveBeenCalledTimes(1);
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/main/workbench/__tests__/workbenchManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,46 @@ describe('workbench tasks', () => {
expect(s.wb.getSnapshot().tasks).toEqual([]);
});

it('records the launch HEAD and reads the task changes against it, in the task folder only', async () => {
const s = await withFolder();
const base = 'a'.repeat(40);
s.exec.mockImplementation(async (_file: string, args: readonly string[]) => {
if (args.includes('rev-parse')) return { code: 0, stdout: `${base}\n`, stderr: '' };
if (args.includes('--numstat')) return { code: 0, stdout: '1\t0\ta.ts\0', stderr: '' };
if (args.includes('--name-status')) return { code: 0, stdout: 'M\0a.ts\0', stderr: '' };
if (args.includes('--patch')) return { code: 0, stdout: 'diff --git a/a.ts b/a.ts\n@@ -0,0 +1 @@\n+x\n', stderr: '' };
return { code: 0, stdout: '', stderr: '' };
});
const id = s.wb.createTask({ title: 'T', prompt: 'p', folderId: s.folderId });
s.wb.launchTask(id);
await flush();
const saved = JSON.parse(s.disk.get(path.join(DATA, 'tasks.json'))!);
expect(saved.tasks[0].baseCommit).toBe(base);

const out = await s.wb.taskChanges(id);
expect(out).toMatchObject({ since: 'launch', truncated: false });
expect(out.files).toEqual([
{ path: 'a.ts', oldPath: null, status: 'modified', additions: 1, deletions: 0, patch: expect.stringContaining('+x') },
]);
const gitCalls = s.exec.mock.calls.filter(([file]) => file === '/opt/bin/git').map(([, args]) => args);
for (const args of gitCalls) {
// Hardened, in the task's folder, never an external diff driver.
expect(args.slice(0, 2)).toEqual(['-c', 'core.fsmonitor=false']);
expect(args[args.indexOf('-C') + 1]).toBe('/work/app');
if (args.includes('diff')) expect(args).toContain('--no-ext-diff');
}
expect(gitCalls.some((args) => args.includes('diff') && args.includes(base))).toBe(true);
expect(await codeOfAsync(s.wb.taskChanges('nope000000'))).toBe('NOT_FOUND');
});

it('has no changes to read for a task without a git checkout', async () => {
const s = await started();
await s.wb.addFolder('/work/notes');
const folderId = s.wb.getSnapshot().folders[0]!.id;
const id = s.wb.createTask({ title: 'T', prompt: 'p', folderId });
expect(await codeOfAsync(s.wb.taskChanges(id))).toBe('PRECONDITION_FAILED');
});

it('marks a launch that herdr refuses as failed and cleans up', async () => {
const s = await withFolder();
s.herdr.client.agentStart.mockRejectedValueOnce(new HerdrError('agent_pane_busy', 'busy'));
Expand Down
Loading
Loading