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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"workspace-server"
],
"scripts": {
"prepare": "npm run build",
"prepare": "node scripts/prepare-build.js",
"build": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"test:watch": "npm run test:watch --workspaces --if-present",
Expand Down
64 changes: 64 additions & 0 deletions scripts/prepare-build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Install-time build hook.
*
* `npm install -g git+https://...#<sha>` runs the root `prepare` script but
* refuses to run a workspaces build for a global package ("Workspaces not
* supported for global packages"). This script therefore builds the
* workspace-server bundle directly with esbuild — no npm workspaces involved —
* so install-from-git produces `workspace-server/dist/index.js` and the
* `gemini-workspace-server` bin can require it.
*
* Behaviour:
* - If esbuild is available (devDependencies are present, as they are for
* git-source installs), build the bundle in workspace-server/.
* - If esbuild is NOT available AND a prebuilt dist already exists (committed
* fallback or a registry install that shipped dist via the `files` field),
* skip the build instead of failing.
* - Only fail when there is neither a toolchain to build with nor a prebuilt
* artifact to fall back to.
*/

const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');

const workspaceServerDir = path.join(__dirname, '..', 'workspace-server');
const distIndex = path.join(workspaceServerDir, 'dist', 'index.js');

function esbuildAvailable() {
try {
require.resolve('esbuild');
return true;
} catch {
return false;
}
}

function run(script) {
execFileSync(process.execPath, [script], {
cwd: workspaceServerDir,
stdio: 'inherit',
});
}

if (esbuildAvailable()) {
run('esbuild.config.js');
run('esbuild.headless-login.js');
console.log('prepare-build: workspace-server bundle built.');
} else if (fs.existsSync(distIndex)) {
console.log(
'prepare-build: esbuild not available; using the prebuilt dist/ artifact.',
);
} else {
console.error(
'prepare-build: esbuild is not available and no prebuilt dist/ exists. ' +
'Install dev dependencies or ship a prebuilt dist/.',
);
process.exit(1);
}
436 changes: 436 additions & 0 deletions workspace-server/dist/headless-login.js

Large diffs are not rendered by default.

548 changes: 548 additions & 0 deletions workspace-server/dist/index.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jest.mock('node:path', () => {
});
jest.mock('../../utils/paths', () => ({
PROJECT_ROOT: '/mock/project/root',
STATE_DIR: '/mock/project/root',
ENCRYPTED_TOKEN_PATH: '/mock/project/root/token.json',
ENCRYPTION_MASTER_KEY_PATH: '/mock/project/root/key',
}));
Expand Down
225 changes: 225 additions & 0 deletions workspace-server/src/__tests__/services/GmailService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,231 @@ describe('GmailService', () => {
});
});

describe('send with attachments and ATTACHMENT_ALLOWED_ROOTS gate', () => {
// These tests exercise the real node:fs realpath used by the allowlist
// gate (allowed-roots.ts), so they create real temp directories/symlinks
// for the roots while keeping node:fs/promises (stat/readFile) mocked.
const realFs = jest.requireActual('node:fs') as typeof import('node:fs');
const os = jest.requireActual('node:os') as typeof import('node:os');
const realPath = jest.requireActual(
'node:path',
) as typeof import('node:path');

let tmpRoot: string;
let allowedDir: string;
let siblingDir: string;
const originalAllowedRoots = process.env.ATTACHMENT_ALLOWED_ROOTS;

beforeEach(() => {
(MimeHelper.createMimeMessage as jest.Mock) = jest
.fn()
.mockReturnValue('base64encodedmessage');
(MimeHelper.createMimeMessageWithAttachments as jest.Mock) = jest
.fn()
.mockReturnValue('base64encodedmessage-with-attachments');
(fs.stat as any).mockResolvedValue({
isFile: () => true,
size: 1024,
});
(fs.readFile as any).mockResolvedValue(Buffer.from('file content'));
mockGmailAPI.users.messages.send.mockResolvedValue({
data: { id: 'sent-attach', threadId: null, labelIds: ['SENT'] },
});

// realpathSync resolves /tmp symlinks on macOS, so create + realpath the
// root so the containment comparison uses canonical paths on every OS.
tmpRoot = realFs.realpathSync(
realFs.mkdtempSync(realPath.join(os.tmpdir(), 'gws-allow-')),
);
allowedDir = realPath.join(tmpRoot, 'allowed');
siblingDir = realPath.join(tmpRoot, 'allowed-evil');
realFs.mkdirSync(allowedDir);
realFs.mkdirSync(siblingDir);
});

afterEach(() => {
if (originalAllowedRoots === undefined) {
delete process.env.ATTACHMENT_ALLOWED_ROOTS;
} else {
process.env.ATTACHMENT_ALLOWED_ROOTS = originalAllowedRoots;
}
if (tmpRoot) {
realFs.rmSync(tmpRoot, { recursive: true, force: true });
}
});

it('sends a multipart message for an in-allowlist attachment when the env is SET (gate ACTIVE but permissive)', async () => {
process.env.ATTACHMENT_ALLOWED_ROOTS = allowedDir;
const filePath = realPath.join(allowedDir, 'report.pdf');
realFs.writeFileSync(filePath, 'pdf');

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'With attachment',
body: 'See attached.',
attachments: [{ filePath, mimeType: 'application/pdf' }],
});

expect(MimeHelper.createMimeMessageWithAttachments).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
filename: 'report.pdf',
contentType: 'application/pdf',
}),
],
}),
);
expect(MimeHelper.createMimeMessage).not.toHaveBeenCalled();
const response = JSON.parse(result.content[0].text);
expect(response.status).toBe('sent');
});

it('rejects a path outside ATTACHMENT_ALLOWED_ROOTS when the env is SET (gate ACTIVE)', async () => {
process.env.ATTACHMENT_ALLOWED_ROOTS = allowedDir;
const outsidePath = realPath.join(siblingDir, 'secret.pdf');
realFs.writeFileSync(outsidePath, 'secret');

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'Outside root',
body: 'Body',
attachments: [{ filePath: outsidePath }],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toContain('not within ATTACHMENT_ALLOWED_ROOTS');
expect(fs.readFile).not.toHaveBeenCalled();
});

it('rejects a symlink that resolves outside the allowed root (no symlink escape)', async () => {
process.env.ATTACHMENT_ALLOWED_ROOTS = allowedDir;
const realTarget = realPath.join(siblingDir, 'target.pdf');
realFs.writeFileSync(realTarget, 'secret');
const linkPath = realPath.join(allowedDir, 'link.pdf');
realFs.symlinkSync(realTarget, linkPath);

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'Symlink escape',
body: 'Body',
attachments: [{ filePath: linkPath }],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toContain('not within ATTACHMENT_ALLOWED_ROOTS');
});

it('skips a non-existent root with a warning while a valid second root still gates correctly', async () => {
const missingRoot = realPath.join(tmpRoot, 'does-not-exist');
process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingRoot}${realPath.delimiter}${allowedDir}`;
const warnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);

const insideOk = realPath.join(allowedDir, 'ok.pdf');
realFs.writeFileSync(insideOk, 'pdf');
const okResult = await gmailService.send({
to: 'recipient@example.com',
subject: 'Valid via second root',
body: 'Body',
attachments: [{ filePath: insideOk }],
});
expect(JSON.parse(okResult.content[0].text).status).toBe('sent');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('skipping unresolvable root'),
);

const outside = realPath.join(siblingDir, 'nope.pdf');
realFs.writeFileSync(outside, 'x');
const rejectResult = await gmailService.send({
to: 'recipient@example.com',
subject: 'Outside via second root',
body: 'Body',
attachments: [{ filePath: outside }],
});
expect(JSON.parse(rejectResult.content[0].text).error).toContain(
'not within ATTACHMENT_ALLOWED_ROOTS',
);
warnSpy.mockRestore();
});

it('fails closed when every configured root is missing', async () => {
const missingA = realPath.join(tmpRoot, 'missing-a');
const missingB = realPath.join(tmpRoot, 'missing-b');
process.env.ATTACHMENT_ALLOWED_ROOTS = `${missingA}${realPath.delimiter}${missingB}`;
jest.spyOn(console, 'warn').mockImplementation(() => undefined);

const filePath = realPath.join(allowedDir, 'file.pdf');
realFs.writeFileSync(filePath, 'pdf');

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'All roots missing',
body: 'Body',
attachments: [{ filePath }],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toContain('no configured root could be resolved');
});

it('rejects an attachment whose total raw size exceeds the shared cap before send', async () => {
process.env.ATTACHMENT_ALLOWED_ROOTS = allowedDir;
(fs.stat as any).mockResolvedValue({
isFile: () => true,
size: 30 * 1024 * 1024,
});
const filePath = realPath.join(allowedDir, 'huge.zip');
realFs.writeFileSync(filePath, 'big');

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'Too large',
body: 'Body',
attachments: [{ filePath }],
});

const response = JSON.parse(result.content[0].text);
expect(response.error).toContain('exceeds the maximum allowed limit');
expect(fs.readFile).not.toHaveBeenCalled();
});

it('sends a plain message (createMimeMessage) when attachments are omitted', async () => {
delete process.env.ATTACHMENT_ALLOWED_ROOTS;

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'No attachment',
body: 'Body',
});

expect(MimeHelper.createMimeMessage).toHaveBeenCalled();
expect(
MimeHelper.createMimeMessageWithAttachments,
).not.toHaveBeenCalled();
expect(JSON.parse(result.content[0].text).status).toBe('sent');
});

it('applies no path restriction when ATTACHMENT_ALLOWED_ROOTS is unset (any absolute path passes the gate)', async () => {
delete process.env.ATTACHMENT_ALLOWED_ROOTS;
// A path under the sibling dir would be rejected if the gate were active;
// with the env unset it must pass the gate (size/file checks still apply).
const filePath = realPath.join(siblingDir, 'anywhere.pdf');
realFs.writeFileSync(filePath, 'pdf');

const result = await gmailService.send({
to: 'recipient@example.com',
subject: 'Unset gate',
body: 'Body',
attachments: [{ filePath }],
});

expect(MimeHelper.createMimeMessageWithAttachments).toHaveBeenCalled();
expect(JSON.parse(result.content[0].text).status).toBe('sent');
});
});

describe('createDraft', () => {
beforeEach(async () => {
(MimeHelper.createMimeMessage as jest.Mock) = jest
Expand Down
40 changes: 40 additions & 0 deletions workspace-server/src/__tests__/utils/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,44 @@ describe('paths utils', () => {
expect(PROJECT_ROOT.endsWith('workspace-server')).toBe(false);
});
});

describe('STATE_DIR', () => {
const reload = () => {
jest.resetModules();
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../utils/paths') as typeof import('../../utils/paths');
};

afterEach(() => {
delete process.env['WORKSPACE_STATE_DIR'];
jest.resetModules();
});

it('defaults to PROJECT_ROOT when WORKSPACE_STATE_DIR is unset', () => {
delete process.env['WORKSPACE_STATE_DIR'];
const m = reload();
expect(m.STATE_DIR).toBe(m.PROJECT_ROOT);
expect(m.ENCRYPTED_TOKEN_PATH).toBe(
path.join(m.PROJECT_ROOT, 'gemini-cli-workspace-token.json'),
);
expect(m.ENCRYPTION_MASTER_KEY_PATH).toBe(
path.join(m.PROJECT_ROOT, '.gemini-cli-workspace-master-key'),
);
});

it('honors WORKSPACE_STATE_DIR for token and master-key paths', () => {
process.env['WORKSPACE_STATE_DIR'] = '/var/lib/workspace-state';
const m = reload();
expect(m.STATE_DIR).toBe('/var/lib/workspace-state');
expect(m.ENCRYPTED_TOKEN_PATH).toBe(
path.join('/var/lib/workspace-state', 'gemini-cli-workspace-token.json'),
);
expect(m.ENCRYPTION_MASTER_KEY_PATH).toBe(
path.join(
'/var/lib/workspace-state',
'.gemini-cli-workspace-master-key',
),
);
});
});
});
15 changes: 8 additions & 7 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ const emailComposeSchema = {
.boolean()
.optional()
.describe('Whether the body is HTML (default: false).'),
attachments: z
.array(gmailAttachmentSchema)
.optional()
.describe(
'Files to attach. Each entry must reference an absolute local path. Download attachments first with gmail.downloadAttachment if needed.',
),
};

// Dynamically import version from package.json
Expand Down Expand Up @@ -1698,7 +1704,8 @@ System labels that can be modified:
registerTool(
'gmail.send',
{
description: 'Send an email message.',
description:
'Send an email message, optionally with file attachments referenced by absolute local path.',
inputSchema: emailComposeSchema,
},
gmailService.send,
Expand All @@ -1716,12 +1723,6 @@ System labels that can be modified:
.describe(
'The thread ID to create the draft as a reply to. When provided, the draft will be linked to the existing thread with appropriate reply headers.',
),
attachments: z
.array(gmailAttachmentSchema)
.optional()
.describe(
'Files to attach to the draft. Each entry must reference an absolute local path. Download attachments first with gmail.downloadAttachment if needed.',
),
},
},
gmailService.createDraft,
Expand Down
Loading
Loading