diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d0388f1422..b737bdd29f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1750,7 +1750,15 @@ "unavailableClaudeLinux": "On Linux, Claude Code answers @mentions only in an invite-only pod, and this one isn’t. Its owner can switch it to Invite-only under “Who can join” in pod settings. Or connect Claude Code from your own sessions with the command below, or choose “Run it here” for a hosted agent.", "unavailableClaude": "Claude Code can’t answer @mentions from {{os}} yet: Commonly runs it in a sandbox, and on {{os}} that sandbox can’t run it. Connect Claude Code from your own sessions with the command below, or choose “Run it here” for a hosted agent.", "unavailableCodex": "The Codex CLI can’t answer @mentions from {{os}} yet. Choose “Run it here” for a hosted agent, or run the command on a Mac. Linux is not tested yet.", - "useDifferentRunner": "Use a different adapter" + "useDifferentRunner": "Use a different adapter", + "repo": { + "label": "Work in my repo", + "hint": "Runs in the folder you start it from, confined to that folder. Every pod this agent is in must be private and invite-only; if one is not, it will not read your folder. It uses its own scratch folder, or refuses to start if you ticked an option below. Without write, it reads only that folder and nothing else on this computer. With write, the commands it runs can read beyond the folder.", + "write": "Let it edit files and run commands (--allow write)", + "web": "Let it fetch web pages (--allow web)", + "folder": "Open your terminal in your project's folder (not your home folder) before you paste the command. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", + "saved": "Write and web are saved when the agent first starts, and unticking a box here does not remove them later. Stop the running listener first. Then change them with: commonly agent detach --force, followed by a new run with the new choice." + } }, "mode": { "label": "Where should it run?", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index f5ea3e5fe2..2e03dcb507 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1809,7 +1809,15 @@ "unavailableClaude": "Claude Code 在 {{os}} 上暂时不能回复 @提及:Commonly 要把它放进沙箱里运行,而 Commonly 用的沙箱在 {{os}} 上还做不到。你可以用下面的命令把 Commonly 的工具接进自己的 Claude Code 会话,或者选择“在这里运行”,改用托管智能体。", "unavailableCodex": "Codex CLI 在 {{os}} 上暂时不能回复 @提及。你可以选择“在这里运行”,改用托管智能体;也可以换到 Mac 机器上运行这条命令(Linux 尚未测试)。", "toolCodex": "Codex CLI", - "useDifferentRunner": "换一种适配器" + "useDifferentRunner": "换一种适配器", + "repo": { + "label": "在我的仓库里工作", + "hint": "在你启动它的那个文件夹里运行,只能访问这个文件夹。这个智能体所在的每个 Pod 都必须是私密的邀请制 Pod;只要有一个不是,它就不会读取你的文件夹,而是改在自己的暂存文件夹里运行;如果你勾选了下面的选项,它会拒绝启动。不勾选写入时,它只读取这个文件夹,读不到这台电脑上的其他内容。勾选写入后,它运行的命令能读取文件夹以外的内容。", + "write": "允许它编辑文件、运行命令(--allow write)", + "web": "允许它抓取网页(--allow web)", + "folder": "粘贴命令之前,先在终端里打开你的项目文件夹(别在个人文件夹里)。无论选哪一项,它都能把读到的内容传出这台电脑,所以只在你信任的 Pod 里授予。", + "saved": "写入和联网权限在智能体第一次启动时保存,之后在这里取消勾选并不会移除它们。修改前请先停止正在运行的监听器。然后运行 commonly agent detach --force,再用新的选择重新运行。" + } }, "mode": { "label": "在哪里运行?", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 39e0126864..dae9d81d8d 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -432,3 +432,47 @@ describe('BYO listener snippet', () => { }); }); }); + +// TASK-705: "Work in my repo" only ever adds a folder step and the two existing +// opt-ins to the same command. It has no path that skips the sandbox. +describe('BYO listener: Work in my repo', () => { + const PRIVATE_POD = { joinPolicy: 'invite-only', publicRead: false, communityListed: false }; + const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'; + const runLine = () => Array.from(document.querySelectorAll('.v2-byo__pre')) + .map((el) => el.textContent || '') + .find((txt) => /commonly agent run /.test(txt)); + + beforeEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { value: MAC_UA, configurable: true }); + }); + + test('off by default: the command is unchanged and carries no --allow', async () => { + await issueToken(undefined, PRIVATE_POD); + expect(screen.getByTestId('byo-repo-toggle')).not.toBeChecked(); + expect(runLine()).not.toMatch(/--allow|cd /); + }); + + test('choosing it adds no cd step (the note says to run from the project folder), and write/web add only their own --allow', async () => { + await issueToken(undefined, PRIVATE_POD); + fireEvent.click(screen.getByTestId('byo-repo-toggle')); + expect(runLine()).toMatch(/commonly agent run \S+ --adapter claude$/); + fireEvent.click(screen.getByTestId('byo-repo-write')); + expect(runLine()).toMatch(/--adapter claude --allow write$/); + fireEvent.click(screen.getByTestId('byo-repo-web')); + expect(runLine()).toMatch(/--adapter claude --allow write,web$/); + expect(runLine()).not.toMatch(/dangerously|skip-permissions|cd /); + expect(screen.getByTestId('byo-repo-saved-note')).toHaveTextContent(/detach/); + }); + + test('not offered in a pod that is not confirmed private', async () => { + await issueToken(undefined, { joinPolicy: 'open', publicRead: false, communityListed: false }); + expect(screen.queryByTestId('byo-repo-choice')).toBeNull(); + expect(runLine()).not.toMatch(/--allow/); + }); + + test('not offered for Codex, where --allow does not apply', async () => { + await issueToken('codex', PRIVATE_POD); + expect(screen.queryByTestId('byo-repo-choice')).toBeNull(); + expect(runLine()).not.toMatch(/--allow/); + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 8ea9548960..a2a7ea5fde 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -232,6 +232,27 @@ const V2AgentBYO: React.FC = () => { listenAdapter, pods.find((p) => p._id === (issued?.podId || podId)), ); + // TASK-705: "Work in my repo". The CLI already confines a seat to the folder it + // starts in when every pod is confirmed private (workspace trust: Seatbelt on + // a Mac, bwrap on Linux). This choice makes that explicit and carries the two + // opt-ins. There is no unconfined path: write and web only ever ride on + // `--allow`, and the CLI refuses them for a folder it will not confine. + const [workInRepo, setWorkInRepo] = useState(false); + const [allowWrite, setAllowWrite] = useState(false); + const [allowWeb, setAllowWeb] = useState(false); + // Each seat is its own opt-in: starting another install clears all three. + useEffect(() => { + setWorkInRepo(false); setAllowWrite(false); setAllowWeb(false); + }, [issued?.token]); + const repoPod = pods.find((p) => p._id === (issued?.podId || podId)); + const repoChoiceAvailable = listenAdapter === 'claude' + && setupOs !== 'windows' + && podIsConfirmedPrivate(repoPod); + const repoActive = workInRepo && repoChoiceAvailable; + const allowFlag = repoActive + ? [allowWrite && 'write', allowWeb && 'web'].filter(Boolean).join(',') + : ''; + // Live listening verification (#887 class, second half). #888 made the // listening step legible; this makes it CHECKABLE — the page polls the // kernel until THIS agent's runtime token authenticates for the first time @@ -1061,7 +1082,7 @@ const V2AgentBYO: React.FC = () => { // EINVALIDTAGNAME "#" and installs nothing (ux-lead on #2104). The command // names itself; the body above the box says where to run it. const listenSnippet = issued - ? `${CLI_INSTALL_COMMAND}\nexport COMMONLY_API_URL=${apiUrl}\nexport COMMONLY_AGENT_TOKEN=${issued.token}\ncommonly agent run ${issued.agentName} --adapter ${listenAdapter}` + ? `${CLI_INSTALL_COMMAND}\nexport COMMONLY_API_URL=${apiUrl}\nexport COMMONLY_AGENT_TOKEN=${issued.token}\ncommonly agent run ${issued.agentName} --adapter ${listenAdapter}${allowFlag ? ` --allow ${allowFlag}` : ''}` : ''; const cursorSnippet = issued @@ -1684,6 +1705,44 @@ const V2AgentBYO: React.FC = () => { {t(setupOs === 'linux' ? 'agentByo.listen.reachClaudeLinux' : 'agentByo.listen.reachClaude', { name: issued.agentName })}

)} + {listenerAvailable && repoChoiceAvailable && ( +
+ +

{t('agentByo.listen.repo.hint')}

+ {workInRepo && ( + <> + + +

{t('agentByo.listen.repo.folder')}

+

{t('agentByo.listen.repo.saved')}

+ + )} +
+ )} {listenerAvailable && listenAdapter === 'codex' && (

{t('agentByo.listen.reachCodex')}