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
54 changes: 54 additions & 0 deletions backend/__tests__/unit/routes/podInvites.resolve.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const request = require('supertest');
const express = require('express');

jest.mock('../../../middleware/auth', () => (req, res, next) => {
req.userId = 'user1';
next();
});

const mockFindOne = jest.fn();
jest.mock('../../../models/PodInvite', () => ({
PodInvite: { findOne: (...args) => mockFindOne(...args), create: jest.fn() },
}));

// eslint-disable-next-line import/no-unresolved, import/extensions
const Pod = require('../../../models/Pod');
jest.mock('../../../models/Pod');

// eslint-disable-next-line import/no-unresolved, import/extensions
const routes = require('../../../routes/podInvites');

describe('GET /api/invites/:token (authenticated)', () => {
let app;

beforeEach(() => {
jest.clearAllMocks();
app = express();
app.use('/api', routes);
mockFindOne.mockResolvedValue({
token: 'tok123', podId: 'p1', expiresAt: null, isUsable: () => true,
});
});

const resolveForOwner = async (createdBy) => {
const select = jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({
_id: 'p1', name: 'My Workspace', description: 'Your private workspace',
type: 'chat', members: ['user1'], createdBy,
}) });
Pod.findById.mockReturnValue({ select });
const response = await request(app).get('/api/invites/tok123').expect(200);
expect(select).toHaveBeenCalledWith(expect.stringContaining('createdBy'));
return response.body;
};

it('marks the creator as owner without exposing their ID in the invite payload', async () => {
const body = await resolveForOwner('user1');
expect(body.isOwner).toBe(true);
expect(body.pod).not.toHaveProperty('createdBy');
});

it('marks another member as a guest of the workspace', async () => {
const body = await resolveForOwner('other-user');
expect(body.isOwner).toBe(false);
});
});
5 changes: 4 additions & 1 deletion backend/routes/podInvites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ router.get('/invites/:token', inviteReadRateLimit, auth, async (req: any, res: a
return res.status(404).json({ msg: 'Invite invalid or expired' });
}
const pod = await Pod.findById(invite.podId)
.select('_id name description type members createdAt')
.select('_id name description type members createdBy createdAt')
.lean();
if (!pod) return res.status(404).json({ msg: 'Pod no longer exists' });
const alreadyMember = (pod.members || []).some(
Expand All @@ -216,6 +216,9 @@ router.get('/invites/:token', inviteReadRateLimit, auth, async (req: any, res: a
memberCount: (pod.members || []).length,
},
alreadyMember,
// The invite page must distinguish the owner's own workspace from one
// shared by someone else. Keep the owner ID private; send only this fact.
isOwner: String(pod.createdBy || '') === String(userId),
expiresAt: invite.expiresAt,
});
} catch (err: any) {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@
"fallbackPod": "a pod",
"untitledPod": "Untitled pod",
"invitedTo": "You've been invited to {{podName}}",
"sharedWorkspaceDescription": "A private workspace shared with you",
"previewMeta_one": "{{formattedCount}} member · humans and agents working together on Commonly",
"previewMeta_other": "{{formattedCount}} members · humans and agents working together on Commonly",
"memberCount_one": "{{formattedCount}} member",
Expand All @@ -339,6 +340,10 @@
"joinFailed": "Could not join — try again."
}
},
"podNames": {
"otherWorkspace": "{{name}} ({{owner}})",
"shared": "shared"
},
"firstRun": {
"eyebrow": "Your first five minutes",
"title": "Bring your agent into your pod",
Expand Down Expand Up @@ -1352,8 +1357,9 @@
"chooseFirst": "Choose where it runs first"
},
"footnote": {
"preferCli": "Prefer the CLI?",
"then": "then",
"cliRoute": "Prefer the CLI? Choose “{{byo}}”; after install, the next step shows the listener command that answers @mentions.",
"cliRouteClaudeUnavailable": "Prefer the CLI? Choose “{{byo}}”; after install, connect Claude Code to Commonly from your own sessions. To answer @mentions, run the listener on a supported computer or choose “{{hosted}}”.",
"cliRouteCodexUnavailable": "Prefer the CLI? Choose “{{byo}}”; after install, the next step explains how to run the listener on a Mac or Linux computer. To answer @mentions from Windows, choose “{{hosted}}”.",
"notSure": "Not sure which path?",
"mcpVsCli": "MCP vs CLI vs SDK",
"fullWalkthrough": "full walkthrough"
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@
"fallbackPod": "一个 Pod",
"untitledPod": "未命名 Pod",
"invitedTo": "你受邀加入 {{podName}}",
"sharedWorkspaceDescription": "与你共享的私人工作空间",
"signUpToJoin": "注册并加入",
"alreadyHaveAccount": "已有账号?<login>登录</login>",
"goToPods": "前往你的 Pod",
Expand All @@ -339,6 +340,10 @@
"previewMeta_one": "{{formattedCount}}位成员 · 人与智能体在 Commonly 中共同协作",
"previewMeta_other": "{{formattedCount}}位成员 · 人与智能体在 Commonly 中共同协作"
},
"podNames": {
"otherWorkspace": "{{name}}({{owner}})",
"shared": "他人共享"
},
"firstRun": {
"eyebrow": "最初的五分钟",
"title": "把你的智能体请进你的 Pod",
Expand Down Expand Up @@ -1412,8 +1417,9 @@
"chooseFirst": "先选择运行位置"
},
"footnote": {
"preferCli": "更喜欢用 CLI?",
"then": "然后",
"cliRoute": "更喜欢用 CLI?选择「{{byo}}」;安装后下一步会显示回复 @提及的监听命令。",
"cliRouteClaudeUnavailable": "更喜欢用 CLI?选择「{{byo}}」;安装后可以在自己的 Claude Code 会话中连接 Commonly。要回复 @提及,请在支持的电脑上运行监听命令,或选择「{{hosted}}」。",
"cliRouteCodexUnavailable": "更喜欢用 CLI?选择「{{byo}}」;安装后下一步会说明如何在 Mac 或 Linux 电脑上运行监听命令。要从 Windows 回复 @提及,请选择「{{hosted}}」。",
"notSure": "不确定用哪条路径?",
"mcpVsCli": "MCP、CLI 与 SDK 对比",
"fullWalkthrough": "完整教程"
Expand Down
55 changes: 45 additions & 10 deletions frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// @ts-nocheck
// The BYO page's footnote is the first command a stranger copies, and the docs
// (#2097) now send them here first. `agent init` has `--language` as a
// requiredOption, so a footnote without it hands out a command that exits 1.
// Pinned after a real user hit exactly that (#2095, 2026-10-09).
// The CLI footnote must describe the connection available after install.
// `agent init` scaffolds a Python webhook and does not connect Claude/Codex.
import React from 'react';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';
Expand Down Expand Up @@ -42,7 +40,7 @@ const authValue = {
beforeEach(() => {
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/hosted/availability')) {
return Promise.resolve({ data: { configured: false, caps: { agentsPerUser: 1, turnsPerDay: 200 } } });
return Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 200 } } });
}
if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat', createdBy: { _id: 'u1' } }] });
if (url === '/api/machines') return Promise.resolve({ data: { machines: [], offlineAfterMs: 90000 } });
Expand All @@ -59,10 +57,47 @@ const renderPage = () => render(
);

describe('V2AgentBYO footnote', () => {
test('the CLI init command it shows carries every requiredOption of `agent init`', async () => {
const modeTitle = (testId) => screen.getByTestId(testId).querySelector('.v2-byo__mode-title').textContent;
const realUserAgent = window.navigator.userAgent;
const setUserAgent = (value) => Object.defineProperty(window.navigator, 'userAgent', { value, configurable: true });
afterEach(() => {
setUserAgent(realUserAgent);
window.history.replaceState({}, '', '/');
});

test('names the runtime mode leading to the listener, even from Run it here', async () => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
renderPage();
const footnote = await screen.findByText(/Prefer the CLI\?/);
await screen.findByTestId('byo-mode-byo');
expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”; after install`);
expect(footnote).toHaveTextContent('the next step shows the listener command');
expect(footnote).not.toHaveTextContent('commonly agent init');

fireEvent.click(await screen.findByTestId('byo-mode-hosted'));
expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”; after install`);
});

test('on Windows, Claude Code guidance names the MCP path without promising a listener command', async () => {
setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
renderPage();
const footnote = await screen.findByText(/Prefer the CLI\?/);
await screen.findByTestId('byo-mode-byo');
expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”`);
expect(footnote).toHaveTextContent('connect Claude Code to Commonly from your own sessions');
expect(footnote).toHaveTextContent(`run the listener on a supported computer or choose “${modeTitle('byo-mode-hosted')}”`);
expect(footnote).not.toHaveTextContent('the next step shows the listener command');
});

test('on Windows, Codex guidance points to Mac/Linux or hosting', async () => {
setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
window.history.replaceState({}, '', '/?adapter=codex');
renderPage();
const code = await screen.findByText(/^commonly agent init /);
// Same order the CLI declares them: --language, --name, --pod.
expect(code.textContent).toBe('commonly agent init --language python --name <n> --pod <podId>');
const footnote = await screen.findByText(/Prefer the CLI\?/);
await screen.findByTestId('byo-mode-byo');
expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”`);
expect(footnote).toHaveTextContent('run the listener on a Mac or Linux computer');
expect(footnote).toHaveTextContent(`To answer @mentions from Windows, choose “${modeTitle('byo-mode-hosted')}”`);
expect(footnote).not.toHaveTextContent('the next step shows the listener command');
});
});
35 changes: 33 additions & 2 deletions frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
// /api/pods, and the old `installablePods[0]` default silently attached
// personal agents to the public room — a prompt-injection surface running with
// the owner's own tokens. The picker must (a) never offer a publicRead pod, and
// (b) default to the user's OWN workspace, never the most-active pod.
// (b) prefer the pod this person opened over their signup workspace, never
// the most-active pod.
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';
import { POD_VISITS_KEY } from '../lib/podRecency';
// This suite owns the BYO request sequence, not the rail's attention query.
jest.mock('../components/V2NavRail', () => () => null);

Expand All @@ -34,7 +36,11 @@ const authValue = {
const renderPage = () => render(
<AuthContext.Provider value={authValue}><MemoryRouter><V2AgentBYO /></MemoryRouter></AuthContext.Provider>,
);
afterEach(() => jest.clearAllMocks());
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();
window.history.replaceState({}, '', '/');
});

describe('V2AgentBYO pod picker safety (HQ funnel incident 2026-07-24)', () => {
test('excludes publicRead pods and defaults to the user\'s own workspace', async () => {
Expand All @@ -61,4 +67,29 @@ describe('V2AgentBYO pod picker safety (HQ funnel incident 2026-07-24)', () => {
expect(screen.getByRole('combobox').value).toBe('team');
expect(screen.queryByText('Commonly HQ (chat)')).not.toBeInTheDocument();
});

test('defaults to the last opened team pod instead of the user\'s workspace', async () => {
localStorage.setItem(POD_VISITS_KEY, JSON.stringify({ ws: 10, team: 20, hq: 30 }));
axios.get.mockResolvedValueOnce({ data: [
{ _id: 'hq', name: 'Commonly HQ', type: 'chat', publicRead: true },
{ _id: 'ws', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1' } },
{ _id: 'team', name: 'Team Project', type: 'team', createdBy: { _id: 'u2' } },
] });
renderPage();
await waitFor(() => expect(screen.getByRole('combobox').value).toBe('team'));
expect(screen.queryByText('Commonly HQ (chat)')).not.toBeInTheDocument();
});

test('a valid deep link wins over visits, and other My Workspace pods name their owner', async () => {
localStorage.setItem(POD_VISITS_KEY, JSON.stringify({ aaa: 30 }));
window.history.replaceState({}, '', '/v2/agents/byo?pod=bbb');
axios.get.mockResolvedValueOnce({ data: [
{ _id: 'aaa', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1', username: 'sam' } },
{ _id: 'bbb', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u2', username: 'alex' } },
] });
renderPage();
await waitFor(() => expect(screen.getByRole('combobox').value).toBe('bbb'));
expect(screen.getByText('My Workspace (chat)')).toBeInTheDocument();
expect(screen.getByText('My Workspace (alex) (chat)')).toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions frontend/src/v2/__tests__/V2InviteRedeem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,36 @@ describe('V2InviteRedeem', () => {
expect(screen.getByText("This is a private 1:1 conversation — it can't be joined with an invite link.")).toBeInTheDocument();
});
});

test('does not call someone else\'s default workspace your private workspace', async () => {
mockApi.get.mockResolvedValueOnce({
token: 'a'.repeat(32),
pod: { _id: 'other-workspace', name: 'My Workspace', description: 'Your private workspace', type: 'chat', memberCount: 2 },
alreadyMember: false,
isOwner: false,
});
render(
<MemoryRouter initialEntries={[`/v2/invite/${'a'.repeat(32)}`]}>
<Routes><Route path="/v2/invite/:token" element={<V2InviteRedeem />} /></Routes>
</MemoryRouter>,
);
expect(await screen.findByText('A private workspace shared with you')).toBeInTheDocument();
expect(screen.queryByText('Your private workspace')).not.toBeInTheDocument();
});

test('keeps the owner\'s private-workspace description on their own invite', async () => {
mockApi.get.mockResolvedValueOnce({
token: 'a'.repeat(32),
pod: { _id: 'own-workspace', name: 'My Workspace', description: 'Your private workspace', type: 'chat', memberCount: 1 },
alreadyMember: true,
isOwner: true,
});
render(
<MemoryRouter initialEntries={[`/v2/invite/${'a'.repeat(32)}`]}>
<Routes><Route path="/v2/invite/:token" element={<V2InviteRedeem />} /></Routes>
</MemoryRouter>,
);
expect(await screen.findByText('Your private workspace')).toBeInTheDocument();
expect(screen.queryByText('A private workspace shared with you')).not.toBeInTheDocument();
});
});
12 changes: 12 additions & 0 deletions frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ describe('V2PodsSidebar — direction C', () => {
expect(within(dm).getByText('1d')).toBeInTheDocument();
});

test('names the owner when another member also has a My Workspace pod', () => {
const own = pod('own', 'My Workspace', 'chat', [human('me')], {
createdBy: { _id: 'me', username: 'me' },
});
const shared = pod('shared', 'My Workspace', 'chat', [human('me'), human('alex'), human('third')], {
createdBy: { _id: 'alex', username: 'alex' },
});
renderSidebar([own, shared]);
expect(screen.getByRole('button', { name: /^My Workspace \(alex\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^My Workspace$/ })).toBeInTheDocument();
});

test('direct rows render the peer character for agents and Paper for unpicked people', () => {
const agentDm = pod('agent-dm', 'Helper Agent', 'agent-dm', [
human('me'), { ...agent('builder-id'), username: 'Builder' },
Expand Down
Loading
Loading