Skip to content
Draft
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
12 changes: 7 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
COPY package.json bun.lock ./
COPY packages/api/package.json packages/api/
COPY packages/cli/package.json packages/cli/
COPY packages/db/package.json packages/db/
COPY packages/db/prisma packages/db/prisma
COPY packages/docs/package.json packages/docs/
Expand Down
35 changes: 35 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"docs": "bun run --cwd packages/docs dev",
"website": "bun run --cwd packages/website dev",
"seed": "bun run --cwd packages/db db:seed",
"proxy": "bun packages/cli/src/index.ts",
"build": "bun run --cwd packages/web build && bun run --cwd packages/docs build && bun run --cwd packages/website build",
"typecheck": "bun run --filter './packages/*' typecheck",
"lint": "bun run --cwd packages/web lint && bun run --cwd packages/docs lint && bun run --cwd packages/website lint",
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export type AuditEntryResponse = {
recordTitle: string | null;
query: string | null;
outcome: 'allowed' | 'denied';
// The web app's agent pages, or the MCP server.
via: 'web' | 'mcp';
// The web app's agent pages, the MCP server, or the `proxy` command line.
via: 'web' | 'mcp' | 'cli';
};

const querySchema = z.object({agentId: z.string().min(1).optional(), connectionId: z.string().min(1).optional()});
Expand Down
15 changes: 13 additions & 2 deletions packages/api/src/agent-side/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ mock.module('./tools', () => ({runAgentTool, listAgentTools, listAgentConnection

const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'dot', name: 'Dots'};

function makeRequest(params: Record<string, string>, body: unknown = undefined) {
return {agent: signedIn, params, body, query: {}} as never;
function makeRequest(params: Record<string, string>, body: unknown = undefined, headers: Record<string, string> = {}) {
return {agent: signedIn, params, body, query: {}, headers} as never;
}

beforeEach(() => {
Expand All @@ -35,6 +35,17 @@ describe('handleAgentRunToolRoute', () => {
expect(runAgentTool).toHaveBeenCalledWith({agent: signedIn, via: 'web'}, 'mail-1', 'emails_list', undefined);
});

test('a run from the proxy command line is marked as one, for the activity log alone', async () => {
const {handleAgentRunToolRoute} = await import('./route');
await handleAgentRunToolRoute(makeRequest({connectionId: 'tt-1', toolName: 'tasks_list'}, {params: {}}, {'x-proxy-client': 'cli'}));
await handleAgentRunToolRoute(makeRequest({connectionId: 'tt-1', toolName: 'tasks_list'}, {params: {}}, {'x-proxy-client': 'mcp'}));

expect(runAgentTool.mock.calls.map((call) => call[0])).toEqual([
{agent: signedIn, via: 'cli'},
{agent: signedIn, via: 'web'},
]);
});

test('a body that is not an object is refused', async () => {
const {handleAgentRunToolRoute} = await import('./route');
const result = await handleAgentRunToolRoute(makeRequest({connectionId: 'mail-1', toolName: 'emails_list'}, 'nonsense'));
Expand Down
14 changes: 11 additions & 3 deletions packages/api/src/agent-side/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type {Tool} from '@proxy/integrations';
import type {AgentRequest} from '../server/middleware/require-agent';
import {listAgentConnections, listAgentTools, runAgentTool, type AgentConnection, type ToolResult} from './tools';

// The agent side over HTTP, for the web app's agent pages: the same connections, tools and runs
// as the MCP server, signed in with the agent's cookie.
// The agent side over HTTP, for the web app's agent pages and the `proxy` command line: the same
// connections, tools and runs as the MCP server, signed in with the agent's cookie.

/** Who the agent is and what it can do with each connection; connections it can't use are left out. */
export function handleAgentMeRoute(request: AgentRequest): Promise<Result<{agent: {id: string; providerId: string; name: string}; connections: AgentConnection[]}, ApiError>> {
Expand All @@ -22,10 +22,18 @@ export function handleAgentListToolsRoute(request: AgentRequest): Promise<Result

const runToolBodySchema = z.object({params: z.unknown().optional()});

// The header the `proxy` command line sends, so the activity log can say a request came from it.
// Anyone can send it: it only marks where a request came from, and access is the same either way.
export const CLIENT_HEADER = 'x-proxy-client';

function viaOf(request: AgentRequest): 'web' | 'cli' {
return request.headers[CLIENT_HEADER] === 'cli' ? 'cli' : 'web';
}

/** `POST /api/agent/connections/:connectionId/tools/:toolName` with `{params}`. */
export function handleAgentRunToolRoute(request: AgentRequest): Promise<Result<ToolResult, ApiError>> {
return Do(async ($) => {
const {params} = $(parseSchema(runToolBodySchema, request.body ?? {}));
return $(await runAgentTool({agent: request.agent, via: 'web'}, request.params.connectionId ?? '', request.params.toolName ?? '', params));
return $(await runAgentTool({agent: request.agent, via: viaOf(request)}, request.params.connectionId ?? '', request.params.toolName ?? '', params));
});
}
57 changes: 55 additions & 2 deletions packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,28 @@ const card = {id: 'rec-1', values: {label: 'Personal Visa', number: '4242'}, upd
const inboxEmail = {id: 'inbox-7-12', values: {folder: 'Inbox', subject: 'Invoice #20931'}, updatedAt: '2026-10-01T00:00:00.000Z'};
const draft = {id: 'drafts-7-3', values: {folder: 'Draft', from: 'alex@example.com', to: 'sam@example.com', subject: 'Re: Q3 planning', body: 'Draft body'}, updatedAt: '2026-10-01T00:00:00.000Z'};

// Reads and edits tasks by default; completing them is off.
const ticktickConnection = {
id: 'ticktick-1',
integrationId: 'ticktick',
account: 'alex@example.com',
createdAt: new Date(),
defaults: [{actionId: 'read'}, {actionId: 'edit'}],
credential: 'v1.x.y.z',
};
const openTask = {
id: 'task:64f0a1b2c3d4e5f6a7b8c901:64f0a1b2c3d4e5f6a7b8c9a1',
values: {title: 'Buy milk', kind: 'Task', status: 'Open', priority: 'Medium', content: ''},
updatedAt: '2026-10-13T18:39:42.531Z',
};

const archive = mock();
const send = mock();
const sendNew = mock();
const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send}, newCommands: {sendNew}};
const complete = mock();
const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send, complete}, newCommands: {sendNew}};

function useConnection(connection: typeof infoConnection | typeof emailConnection | typeof notionConnection) {
function useConnection(connection: typeof infoConnection | typeof emailConnection | typeof notionConnection | typeof ticktickConnection) {
loadConnection.mockResolvedValue(Ok({connection, integration: integration(connection.integrationId), connector}));
}

Expand Down Expand Up @@ -325,4 +341,41 @@ describe('nested collections', () => {
expect(connector.list).not.toHaveBeenCalled();
expect(connector.create).not.toHaveBeenCalled();
});

test('editing a TickTick task never completes it: that takes Complete tasks, refused and logged until it is given', async () => {
useConnection(ticktickConnection);
connector.get.mockResolvedValue(Ok(openTask));
connector.update.mockResolvedValue(Ok(openTask));
complete.mockResolvedValue(Ok(null));
const {runAgentTool} = await import('./tools');

const viaEdit = await runAgentTool(caller, 'ticktick-1', 'tasks_update', {id: openTask.id, status: 'Completed'});
expect(viaEdit.unwrapErr()).toMatchObject({kind: 'validation_error', message: expect.stringContaining('takes no status')});
expect(connector.update).not.toHaveBeenCalled();

const denied = await runAgentTool(caller, 'ticktick-1', 'tasks_complete', {id: openTask.id});
expect(denied.unwrapErr().kind).toBe('forbidden');
expect(complete).not.toHaveBeenCalled();
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({connectionId: 'ticktick-1', collectionId: 'tasks', action: 'complete', outcome: 'denied'});

getAgent.mockResolvedValue(Ok({...agent, grants: [{connectionId: 'ticktick-1', actionId: 'complete', allowed: true}]}));
const allowed = await runAgentTool(caller, 'ticktick-1', 'tasks_complete', {id: openTask.id});
expect(allowed.unwrap()).toEqual({record: null});
expect(complete.mock.calls[0]?.[1]).toBe(openTask.id);
expect(logAgentRequest.mock.calls[1]?.[0]).toMatchObject({action: 'complete', recordTitle: 'Buy milk', outcome: 'allowed'});
});

test('a completed TickTick task is not completed again, and a list is not edited', async () => {
useConnection(ticktickConnection);
getAgent.mockResolvedValue(Ok({...agent, grants: [{connectionId: 'ticktick-1', actionId: 'complete', allowed: true}]}));
const {runAgentTool} = await import('./tools');

connector.get.mockResolvedValue(Ok({...openTask, values: {...openTask.values, status: 'Completed'}}));
expect((await runAgentTool(caller, 'ticktick-1', 'tasks_complete', {id: openTask.id})).unwrapErr().kind).toBe('validation_error');

connector.get.mockResolvedValue(Ok({id: 'project:64f0a1b2c3d4e5f6a7b8c901', values: {title: 'Errands', kind: 'List'}, updatedAt: '', hasChildren: true}));
expect((await runAgentTool(caller, 'ticktick-1', 'tasks_update', {id: 'project:64f0a1b2c3d4e5f6a7b8c901', title: 'Chores'})).unwrapErr().kind).toBe('validation_error');
expect(complete).not.toHaveBeenCalled();
expect(connector.update).not.toHaveBeenCalled();
});
});
5 changes: 3 additions & 2 deletions packages/api/src/agent-side/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ export function listAgentTools(signedIn: SignedInAgent, connectionId: string): P
});
}

// Who runs a tool and which way it came in: the web app's agent pages, or the MCP server.
export type AgentCaller = {agent: SignedInAgent; via: 'web' | 'mcp'};
// Who runs a tool and which way it came in: the web app's agent pages, the MCP server, or the
// `proxy` command line.
export type AgentCaller = {agent: SignedInAgent; via: 'web' | 'mcp' | 'cli'};

type ToolRun = {caller: AgentCaller; target: RecordTarget; connector: Connector; tool: Tool};

Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/connections/ticktick/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {z} from 'zod';

/** What a TickTick connection stores, encrypted: the personal API token it signs in with. */
export const ticktickCredentialSchema = z.object({token: z.string().min(1)});

export type TickTickCredential = z.infer<typeof ticktickCredentialSchema>;
97 changes: 97 additions & 0 deletions packages/api/src/connections/ticktick/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {beforeEach, describe, expect, mock, test} from 'bun:test';
import {Err, Ok, type Result} from 'ts-results-es';

import {ApiErr, type ApiError} from '@proxy/utils';

const listConnections = mock();
const createConnection = mock();

mock.module('@proxy/db/connection', () => ({listConnections, createConnection}));

const getUserOrgId = mock();

mock.module('@proxy/db/organization', () => ({getUserOrgId}));

const checkToken = mock();

mock.module('./ticktick-api', () => ({checkToken}));

const encryptSecret = mock((plaintext: string): Result<string, ApiError> => Ok(`encrypted:${plaintext}`));

mock.module('../../utils/secret-crypto', () => ({encryptSecret}));

const createdRow = {
id: 'conn-1',
integrationId: 'ticktick',
account: 'alex@example.com',
createdAt: new Date('2026-10-01T00:00:00Z'),
defaults: [{actionId: 'read'}],
};

const body = {account: 'alex@example.com', token: 'tp_0123456789abcdef'};

function makeRequest(body: unknown) {
return {user: {userId: 'user-1', email: 'alex@example.com'}, params: {}, body} as never;
}

beforeEach(() => {
mock.clearAllMocks();
getUserOrgId.mockResolvedValue(Ok('org-1'));
listConnections.mockResolvedValue(Ok([]));
createConnection.mockResolvedValue(Ok(createdRow));
checkToken.mockResolvedValue(Ok(undefined));
});

describe('handleConnectTickTickRoute', () => {
test('checks the token first, then stores it encrypted with reading on by default', async () => {
const {handleConnectTickTickRoute} = await import('./route');
const result = await handleConnectTickTickRoute(makeRequest({account: ' alex@example.com ', token: ' tp_0123456789abcdef\n'}));

expect(checkToken).toHaveBeenCalledWith('tp_0123456789abcdef');
expect(createConnection).toHaveBeenCalledWith({
orgId: 'org-1',
integrationId: 'ticktick',
account: 'alex@example.com',
credential: `encrypted:${JSON.stringify({token: 'tp_0123456789abcdef'})}`,
defaults: ['read'],
});
expect(result.unwrap()).toEqual({id: 'conn-1', integrationId: 'ticktick', account: 'alex@example.com', defaults: ['read']});
});

test('saves nothing when TickTick turns the token down', async () => {
const {handleConnectTickTickRoute} = await import('./route');
checkToken.mockResolvedValue(Err(ApiErr.credentialsRejected()));
const result = await handleConnectTickTickRoute(makeRequest(body));

expect(result.unwrapErr().kind).toBe('credentials_rejected');
expect(createConnection).not.toHaveBeenCalled();
});

test('an account is connected once by its name', async () => {
const {handleConnectTickTickRoute} = await import('./route');
listConnections.mockResolvedValue(Ok([{id: 'conn-0', integrationId: 'ticktick', account: 'alex@example.com', defaults: []}]));

expect((await handleConnectTickTickRoute(makeRequest(body))).unwrapErr().kind).toBe('conflict');
expect((await handleConnectTickTickRoute(makeRequest({...body, account: 'work'}))).isOk()).toBe(true);
});

test('refuses an empty name, and a token that is empty or has spaces in it', async () => {
const {handleConnectTickTickRoute} = await import('./route');
for (const bad of [{account: ' '}, {token: ''}, {token: 'tp_one two'}, {token: 42}]) {
expect((await handleConnectTickTickRoute(makeRequest({...body, ...bad}))).unwrapErr().statusCode, JSON.stringify(bad)).toBe(400);
}
expect(checkToken).not.toHaveBeenCalled();
});
});

describe('handleTestTickTickRoute', () => {
test('checks the token and saves nothing', async () => {
const {handleTestTickTickRoute} = await import('./route');
expect((await handleTestTickTickRoute(makeRequest(body))).isOk()).toBe(true);
expect(checkToken).toHaveBeenCalledWith('tp_0123456789abcdef');
expect(createConnection).not.toHaveBeenCalled();

checkToken.mockResolvedValue(Err(ApiErr.providerUnreachable(new Error('timeout'))));
expect((await handleTestTickTickRoute(makeRequest(body))).unwrapErr().kind).toBe('provider_unreachable');
});
});
Loading