Skip to content
Closed
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
10 changes: 10 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ export const test = base.extend<{
promptRailMotionWindow: Page;
requestHeaderRowWindow: Page;
newTaskTargetWindow: Page;
workspaceMarkdownWindow: Page;
}>({
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
window: async ({}, use) => {
Expand Down Expand Up @@ -641,6 +642,15 @@ export const test = base.extend<{
showWindow: true,
}, use);
},
workspaceMarkdownWindow: async ({}, use) => {
await withE2eWindow({
seed: false,
readinessSelector: '[data-workspace-file="docs/guide with spaces.md"]',
e2eFixtureScenario: 'chat-workspace-markdown',
locale: 'en',
showWindow: true,
}, use);
},
});

export { expect };
55 changes: 55 additions & 0 deletions apps/desktop/e2e/workspace-markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { expect, test } from './fixtures';

test('opens explicit, bare, and sent workspace Markdown references in the side preview', async ({
workspaceMarkdownWindow: page,
}) => {
const transcript = page.locator('[data-chat-scroll-container="true"]');
await expect(transcript).toBeVisible();
await expect(page.locator('[data-workspace-file="tools/setup.command"]')).toHaveCount(0);

const explicitLink = transcript.locator(
'[data-workspace-file="docs/guide with spaces.md"]',
);
await explicitLink.click();
const explicitPreview = page.locator(
'.maka-workspace-file-preview[data-workspace-file="docs/guide with spaces.md"]',
);
await expect(explicitPreview).toBeVisible();
await expect(explicitPreview.getByText('Workspace guide')).toBeVisible();
await expect(transcript.locator('[data-turn-id="turn-workspace-markdown"]')).toBeAttached();

await explicitPreview.getByRole('button', { name: 'Back' }).click();
const bareLink = transcript.locator('[data-workspace-file="docs/notes.md"]');
await bareLink.click();
const barePreview = page.locator(
'.maka-workspace-file-preview[data-workspace-file="docs/notes.md"]',
);
await expect(barePreview.getByText('Workspace notes')).toBeVisible();

await barePreview.getByRole('button', { name: 'Back' }).click();
const sentFileReference = transcript.locator('[data-workspace-file="docs/chip.md"]');
await sentFileReference.click();
const sentFilePreview = page.locator(
'.maka-workspace-file-preview[data-workspace-file="docs/chip.md"]',
);
await expect(sentFilePreview.getByText('Workspace chip')).toBeVisible();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import type { DesktopRuntimeHostClient } from '../runtime-host-client.js';
import {
readBoundedWorkspaceText,
registerRuntimeHostWorkspaceIpc,
} from '../runtime-host-workspace-ipc-main.js';
import type { IpcHandler, ReconnectableReadIpcMain } from '../ipc-reconnect-policy.js';

type WorkspaceHandler = (event: unknown, ...args: unknown[]) => Promise<unknown>;

test('workspace IPC reads Markdown within the limit and opens only Markdown files', async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-ipc-'));
try {
await mkdir(join(workspaceRoot, 'docs'), { recursive: true });
await mkdir(join(workspaceRoot, 'tools'), { recursive: true });
await writeFile(join(workspaceRoot, 'docs', 'guide.md'), '# Guide\n', 'utf8');
await writeFile(join(workspaceRoot, 'tools', 'setup.command'), 'echo unsafe\n', 'utf8');

const opened: string[] = [];
const revealed: string[] = [];
const handlers = new Map<string, WorkspaceHandler>();
registerRuntimeHostWorkspaceIpc({
ipcMain: ipcHarness(handlers),
client: workspaceClient(workspaceRoot),
openPath: async (path) => {
opened.push(path);
return '';
},
showItemInFolder: (path) => revealed.push(path),
});

assert.deepEqual(
await invoke(handlers, 'workspace:readText', 'session-1', 'docs/guide.md'),
{ ok: true, relativePath: 'docs/guide.md', text: '# Guide\n' },
);
assert.deepEqual(
await invoke(handlers, 'workspace:openFile', 'session-1', 'docs/guide.md'),
{ ok: true, opened: 'docs/guide.md' },
);
assert.deepEqual(
await invoke(handlers, 'workspace:revealFile', 'session-1', 'docs/guide.md'),
{ ok: true, opened: 'docs/guide.md' },
);
assert.deepEqual(
await invoke(handlers, 'workspace:openFile', 'session-1', 'tools/setup.command'),
{ ok: false, reason: 'not-a-file' },
);
assert.deepEqual(
await invoke(handlers, 'workspace:revealFile', 'session-1', 'tools/setup.command'),
{ ok: false, reason: 'not-a-file' },
);
const guidePath = await realpath(join(workspaceRoot, 'docs', 'guide.md'));
assert.deepEqual(opened, [guidePath]);
assert.deepEqual(revealed, [guidePath]);
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});

test('workspace IPC bounds Markdown reads at 256 KiB', async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-ipc-limit-'));
try {
const exactText = 'a'.repeat(256 * 1024);
await writeFile(join(workspaceRoot, 'exact.md'), exactText, 'utf8');
await writeFile(join(workspaceRoot, 'too-large.md'), `${exactText}b`, 'utf8');

const handlers = new Map<string, WorkspaceHandler>();
registerRuntimeHostWorkspaceIpc({
ipcMain: ipcHarness(handlers),
client: workspaceClient(workspaceRoot),
});

assert.deepEqual(
await invoke(handlers, 'workspace:readText', 'session-1', 'exact.md'),
{ ok: true, relativePath: 'exact.md', text: exactText },
);
assert.deepEqual(
await invoke(handlers, 'workspace:readText', 'session-1', 'too-large.md'),
{ ok: false, reason: 'too-large', sizeBytes: 256 * 1024 + 1 },
);
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});

test('workspace text read rejects replacement after authorization and reads only the opened inode', async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-ipc-race-'));
const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-ipc-race-out-'));
try {
await mkdir(join(workspaceRoot, 'docs'), { recursive: true });
const guidePath = join(workspaceRoot, 'docs', 'guide.md');
await writeFile(guidePath, '# Safe\n', 'utf8');
const secretPath = join(outsideRoot, 'secret.md');
await writeFile(secretPath, 'secret', 'utf8');

const result = await readBoundedWorkspaceText(
{ workspaceRoot, relativePath: 'docs/guide.md' },
async (authorizedPath) => {
await rename(authorizedPath, `${authorizedPath}.old`);
await symlink(secretPath, authorizedPath);
},
);

assert.deepEqual(result, { ok: false, reason: 'not-allowed' });
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});

function ipcHarness(handlers: Map<string, WorkspaceHandler>): ReconnectableReadIpcMain {
const register = (channel: string, listener: IpcHandler) => {
handlers.set(channel, listener as WorkspaceHandler);
};
return {
handle: register,
handleReconnectableRead: register,
};
}

function workspaceClient(workspaceRoot: string): Pick<DesktopRuntimeHostClient, 'getSession'> {
return {
async getSession() {
return {
workspace: { hostCwd: workspaceRoot },
} as Awaited<ReturnType<DesktopRuntimeHostClient['getSession']>>;
},
};
}

async function invoke(
handlers: Map<string, WorkspaceHandler>,
channel: string,
...args: unknown[]
): Promise<unknown> {
const handler = handlers.get(channel);
assert.ok(handler, `missing ${channel} handler`);
return handler({}, ...args);
}
26 changes: 26 additions & 0 deletions apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,32 @@ describe('useWorkbarController', () => {
);
});

it('opens only Markdown workspace references in the Files tool', async () => {
const { root } = installReactRenderer();
await act(async () =>
renderController(root, createFakeWorkbarServices(), input(session('a'))),
);

await act(async () =>
controller().commands.openWorkspaceFile('a', 'docs/guide.md'),
);
assert.deepEqual(controller().host.workspaceFilePreview, {
sessionId: 'a',
relativePath: 'docs/guide.md',
});
assert.equal(
controller().host.panelsState.right.tabs.some((tab) => tab.kind === 'files'),
true,
);

await act(async () => controller().host.onCloseWorkspaceFilePreview());
assert.equal(controller().host.workspaceFilePreview, null);
await act(async () =>
controller().commands.openWorkspaceFile('a', 'docs/guide.md.txt'),
);
assert.equal(controller().host.workspaceFilePreview, null);
});

it('stops a Terminal whose start resolves after the owner Session changes', async () => {
const { root } = installReactRenderer();
const start = deferred<ShellRunUpdate>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function createBridgeRecorder(): {
inspector: domain('inspector'),
attachments: domain('attachments'),
transcripts: domain('transcripts'),
workspace: domain('workspace'),
} as unknown as MakaBridge,
};
}
Expand Down Expand Up @@ -145,6 +146,10 @@ describe('createDesktopWorkbarServices', () => {
await services.artifacts.openPath('s', 'a');
await services.artifacts.saveAs('s', 'a');

await services.workspace.readText('s', 'docs/guide.md');
await services.workspace.openFile('s', 'docs/guide.md');
await services.workspace.revealFile('s', 'docs/guide.md');

await services.inspector.trace('s', 'cursor-1');
await services.inspector.summary('s');
await services.inspector.context('s');
Expand Down Expand Up @@ -214,6 +219,9 @@ describe('createDesktopWorkbarServices', () => {
'artifacts.subscribeChanges',
'app.openArtifactPath',
'app.saveArtifactAs',
'workspace.readText',
'workspace.openFile',
'workspace.revealFile',
'inspector.trace',
'inspector.summary',
'inspector.context',
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/main/__tests__/workspace-file-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { resolveWorkspaceFile } from '../workspace-file-guard.js';

test('resolves a workspace-relative Markdown file and rejects escapes', async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-file-'));
const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-workspace-file-out-'));
try {
await mkdir(join(workspaceRoot, 'docs'), { recursive: true });
await mkdir(join(workspaceRoot, 'docs..'), { recursive: true });
await writeFile(join(workspaceRoot, 'docs', 'guide.md'), '# Guide\n', 'utf8');
await writeFile(join(workspaceRoot, 'docs..', 'guide.md'), '# Dotted guide\n', 'utf8');
await writeFile(join(outsideRoot, 'secret.md'), 'secret', 'utf8');
await writeFile(join(workspaceRoot, '.env'), 'TOKEN=secret', 'utf8');
await symlink(join(outsideRoot, 'secret.md'), join(workspaceRoot, 'docs', 'escape.md'));
await symlink(join(workspaceRoot, '.env'), join(workspaceRoot, 'docs', 'public.md'));

const resolved = await resolveWorkspaceFile({
workspaceRoot,
relativePath: 'docs/guide.md',
});
assert.equal(resolved.ok, true);
if (resolved.ok) {
assert.equal(resolved.path, await realpath(join(workspaceRoot, 'docs', 'guide.md')));
}

const dottedDirectory = await resolveWorkspaceFile({
workspaceRoot,
relativePath: 'docs../guide.md',
});
assert.equal(dottedDirectory.ok, true);

assert.deepEqual(
await resolveWorkspaceFile({ workspaceRoot, relativePath: '../secret.md' }),
{ ok: false, reason: 'invalid' },
);
assert.deepEqual(
await resolveWorkspaceFile({ workspaceRoot, relativePath: 'docs/escape.md' }),
{ ok: false, reason: 'not-allowed' },
);
assert.deepEqual(
await resolveWorkspaceFile({ workspaceRoot, relativePath: 'docs/missing.md' }),
{ ok: false, reason: 'missing' },
);
assert.deepEqual(
await resolveWorkspaceFile({ workspaceRoot, relativePath: 'docs/public.md' }),
{ ok: false, reason: 'not-a-file' },
);
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
Loading