diff --git a/backend/__tests__/unit/services/byoHandoffService.test.ts b/backend/__tests__/unit/services/byoHandoffService.test.ts new file mode 100644 index 0000000000..ad31ab2398 --- /dev/null +++ b/backend/__tests__/unit/services/byoHandoffService.test.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +import fs from 'fs'; +import path from 'path'; +import { + buildByoHandoffCommand, + buildByoHandoffPayload, + defaultByoHandoffName, +} from '../../../services/byoHandoffService'; + +describe('byoHandoffService', () => { + it('builds the exact OpenCode attach flow for each OS without a token', () => { + const command = buildByoHandoffCommand({ + adapter: 'opencode', + os: 'mac', + podId: '507f1f77bcf86cd799439011', + agentName: 'sam-opencode', + }); + + expect(command).toBe([ + 'npm i -g @commonlyai/cli@latest opencode-ai', + 'commonly login', + 'commonly agent attach opencode --pod 507f1f77bcf86cd799439011 ' + + '--name sam-opencode --env /Users/you/agents/environment.json', + 'commonly agent run sam-opencode', + ].join('\n')); + expect(command).not.toMatch(/token|cm_agent|COMMONLY_/i); + }); + + it('normalizes unsupported OS choices and emits only verified dsh commands', () => { + const payload = buildByoHandoffPayload({ + adapter: 'dsh', + os: 'windows', + podId: 'pod-1', + agentName: 'My DSH', + }); + if ('error' in payload) throw new Error(payload.error); + + expect(payload).toMatchObject({ + kind: 'byo-handoff', + adapter: 'dsh', + agentName: 'my-dsh', + podId: 'pod-1', + os: 'mac', + supportedOs: ['mac'], + }); + expect(payload.commands.mac).toContain('commonly agent attach dsh'); + expect(payload.commands.linux).toBeUndefined(); + expect(payload.commands.windows).toBeUndefined(); + }); + + it('keeps the dsh install command aligned with its smoke-tested adapter version', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../../../../cli/src/lib/adapters/dsh.js'), + 'utf8', + ); + const match = source.match(/TESTED_DSH_VERSIONS = Object\.freeze\(\['([^']+)'\]\)/); + expect(match?.[1]).toBeTruthy(); + + const command = buildByoHandoffCommand({ + adapter: 'dsh', + os: 'mac', + podId: 'pod-1', + agentName: 'my-dsh', + }); + expect(command).toContain(`@deepseek-ai/dsh@${match?.[1]}`); + }); + + it('creates a safe default name from the account handle', () => { + expect(defaultByoHandoffName('Sam Xu', 'claude')).toBe('sam-xu-claude'); + expect(defaultByoHandoffName(undefined, 'codex')).toBe('my-codex'); + }); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js new file mode 100644 index 0000000000..be6b7ba464 --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -0,0 +1,91 @@ +/** + * A hosted agent asked for local repository access must produce the structured + * handoff card, not prose that looks like a command and not a token-bearing + * setup message. + */ +jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } })); +jest.mock('../../../models/Pod', () => ({ + findById: jest.fn(() => ({ lean: jest.fn().mockResolvedValue({ name: 'Build Pod' }) })), +})); +jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +jest.mock('../../../models/User', () => ({ + findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), + findById: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve({ username: 'Sam Xu' }) }) })), +})); +jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); +jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) })); +jest.mock('../../../services/messageClaimService', () => ({ + claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }), + release: jest.fn().mockResolvedValue({ released: true }), +})); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); + +const axios = require('axios').default; +const AgentRun = require('../../../models/AgentRun'); +const AgentMessageService = require('../../../services/agentMessageService'); +const { runAgent } = require('../../../services/nativeRuntimeService'); + +const POD = '507f1f77bcf86cd799439011'; +const install = { + podId: POD, + agentName: 'scout', + instanceId: 'default', + displayName: 'Scout', + installedBy: '507f1f77bcf86cd799439099', + config: { + runtime: { runtimeType: 'native' }, + tools: ['commonly_post_message', 'commonly_offer_local_agent'], + }, +}; +const ask = { + type: 'chat.mention', + eventId: '507f1f77bcf86cd799439012', + payload: { content: 'Please inspect my local repository and fix the failing test.' }, +}; +const callsTool = (name, args) => ({ + status: 200, + headers: {}, + data: { + choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name, arguments: JSON.stringify(args) } }] } }], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +}); +const says = (content) => ({ + status: 200, + headers: {}, + data: { choices: [{ message: { role: 'assistant', content } }], usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 } }, +}); + +describe('commonly_offer_local_agent', () => { + const savedEnv = { ...process.env }; + beforeEach(() => { + jest.clearAllMocks(); + process.env.LITELLM_BASE_URL = 'http://litellm.test'; + process.env.LITELLM_MASTER_KEY = 'test-key'; + process.env.HOSTED_ENGINE = 'native'; + AgentRun.countDocuments.mockResolvedValue(0); + AgentRun.create.mockImplementation(async (doc) => ({ _id: 'run-1', ...doc, save: jest.fn().mockResolvedValue(undefined) })); + }); + afterAll(() => { process.env = savedEnv; }); + + test('posts one exact, token-free handoff card and suppresses model prose', async () => { + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'opencode', os: 'mac' })) + .mockResolvedValueOnce(says('Run the commands in the card above.')); + + const result = await runAgent(install, ask); + + expect(result.status).toBe('succeeded'); + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + const posted = AgentMessageService.postMessage.mock.calls[0][0]; + expect(posted.messageType).toBe('card'); + expect(posted.payload.kind).toBe('byo-handoff'); + expect(posted.payload.agentName).toBe('sam-xu-opencode'); + expect(posted.payload.commands.mac).toContain('commonly agent attach opencode'); + expect(posted.payload.commands.mac).not.toMatch(/cm_agent|COMMONLY_AGENT_TOKEN|token/i); + expect(posted.content).not.toContain('commonly agent attach'); + expect(posted.answeringMessageId).toBeUndefined(); + }); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js index 105a9204ac..5427564d08 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedEngine.test.js @@ -124,8 +124,8 @@ const mention = { }; const BASE_TOOLS = [ - 'commonly_create_task', 'commonly_fetch_url', 'commonly_list_tasks', 'commonly_post_message', 'commonly_read_context', - 'commonly_read_memory', 'commonly_write_memory', + 'commonly_create_task', 'commonly_fetch_url', 'commonly_list_tasks', 'commonly_offer_local_agent', + 'commonly_post_message', 'commonly_read_context', 'commonly_read_memory', 'commonly_write_memory', ]; const call = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } }); const toolCalls = (...calls) => ({ diff --git a/backend/services/byoHandoffService.ts b/backend/services/byoHandoffService.ts new file mode 100644 index 0000000000..f80ace7481 --- /dev/null +++ b/backend/services/byoHandoffService.ts @@ -0,0 +1,126 @@ +/** + * Server-owned handoff cards for a hosted agent that needs the person's local + * workspace. The card contains the CLI attach flow, never a runtime token. + * + * Keep this command builder in the service boundary: the model supplies only + * the adapter, operating system, and an optional display name. Everything that + * reaches a shell-looking code block is validated and composed here. + */ + +export type ByoHandoffAdapter = 'claude' | 'codex' | 'opencode' | 'dsh'; +export type ByoHandoffOs = 'mac' | 'linux' | 'windows'; + +export const BYO_HANDOFF_ADAPTERS: readonly ByoHandoffAdapter[] = [ + 'claude', 'codex', 'opencode', 'dsh', +]; +export const BYO_HANDOFF_OSES: readonly ByoHandoffOs[] = ['mac', 'linux', 'windows']; + +const CLI_INSTALL = 'npm i -g @commonlyai/cli@latest'; +const DSH_INSTALL = `${CLI_INSTALL} @deepseek-ai/dsh@0.2.1-rc.2`; +const OPENCODE_INSTALL = `${CLI_INSTALL} opencode-ai`; + +const isAdapter = (value: unknown): value is ByoHandoffAdapter => ( + typeof value === 'string' && (BYO_HANDOFF_ADAPTERS as readonly string[]).includes(value) +); +const isOs = (value: unknown): value is ByoHandoffOs => ( + typeof value === 'string' && (BYO_HANDOFF_OSES as readonly string[]).includes(value) +); + +const safeName = (value: unknown): string => String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + +const safePodId = (value: unknown): string => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, ''); + +const environmentPath = (os: ByoHandoffOs): string => { + if (os === 'windows') return 'C:\\Users\\you\\agents\\environment.json'; + if (os === 'linux') return '/home/you/agents/environment.json'; + return '/Users/you/agents/environment.json'; +}; + +const installCommand = (adapter: ByoHandoffAdapter): string => { + if (adapter === 'dsh') return DSH_INSTALL; + if (adapter === 'opencode') return OPENCODE_INSTALL; + return CLI_INSTALL; +}; + +/** + * The bytes copied by the in-pod card. Keep the login, attach, and run lines + * together: attaching alone creates a seat but does not make it answer a pod. + */ +export const buildByoHandoffCommand = ({ + adapter, + os, + podId, + agentName, +}: { + adapter: ByoHandoffAdapter; + os: ByoHandoffOs; + podId: string; + agentName: string; +}): string => { + const attach = `commonly agent attach ${adapter} --pod ${podId} --name ${agentName}`; + const withEnvironment = adapter === 'opencode' || adapter === 'dsh' + ? `${attach} --env ${environmentPath(os)}` + : attach; + return [installCommand(adapter), 'commonly login', withEnvironment, `commonly agent run ${agentName}`].join('\n'); +}; + +export interface ByoHandoffPayload { + kind: 'byo-handoff'; + adapter: ByoHandoffAdapter; + agentName: string; + podId: string; + os: ByoHandoffOs; + commands: Partial>; + supportedOs: ByoHandoffOs[]; +} + +export const buildByoHandoffPayload = ({ + adapter, + os, + podId, + agentName, +}: { + adapter: unknown; + os: unknown; + podId: unknown; + agentName: unknown; +}): ByoHandoffPayload | { error: string } => { + if (!isAdapter(adapter)) return { error: 'adapter must be claude, codex, opencode, or dsh' }; + if (!isOs(os)) return { error: 'os must be mac, linux, or windows' }; + const normalizedPodId = safePodId(podId); + const normalizedName = safeName(agentName); + if (!normalizedPodId) return { error: 'pod id is required' }; + if (!/^[a-z0-9-]{2,40}$/.test(normalizedName)) { + return { error: 'agent name must contain 2-40 letters, digits, or dashes' }; + } + + const supportedOs: ByoHandoffOs[] = adapter === 'opencode' || adapter === 'dsh' + ? ['mac'] + : [...BYO_HANDOFF_OSES]; + const selectedOs = supportedOs.includes(os) ? os : supportedOs[0]; + const commands: Partial> = {}; + for (const supported of supportedOs) { + commands[supported] = buildByoHandoffCommand({ + adapter, os: supported, podId: normalizedPodId, agentName: normalizedName, + }); + } + return { + kind: 'byo-handoff', + adapter, + agentName: normalizedName, + podId: normalizedPodId, + os: selectedOs, + commands, + supportedOs, + }; +}; + +export const defaultByoHandoffName = (username: unknown, adapter: ByoHandoffAdapter): string => { + const base = safeName(username) || 'my'; + return `${base}-${adapter}`.slice(0, 40).replace(/-+$/g, '') || `my-${adapter}`; +}; diff --git a/backend/services/hostedEngine.ts b/backend/services/hostedEngine.ts index 27f20ae92d..f6627e9828 100644 --- a/backend/services/hostedEngine.ts +++ b/backend/services/hostedEngine.ts @@ -174,6 +174,7 @@ export const HOSTED_NATIVE_TOOLS: readonly string[] = [ 'commonly_web_search', 'commonly_run_code', 'commonly_fetch_url', + 'commonly_offer_local_agent', ]; /** Recent messages a hosted turn sees, as Scout does. */ @@ -243,6 +244,12 @@ export const hostedNativeSystemPrompt = ( // no hosted tool can do. + 'not on it. You can add tasks and read the board, but you cannot change a task\'s status or ' + 'mark one done: if asked to, say so and point them to the pod\'s board. ' + + 'If someone asks you to inspect or change code, files, or a repository on their own computer, ' + + 'you cannot reach that computer. Ask which local tool they use (Claude Code, Codex, OpenCode, ' + + 'or DeepSeek Harness) and which operating system if either is unknown. Once both are known, ' + + 'call commonly_offer_local_agent instead of posting shell directions yourself. It posts one ' + + 'handoff card with the exact commands and a Copy command button; do not post a second prose ' + + 'handoff about the commands. ' // QA walk 2026-10-16: asked to summarize the pod, a hosted agent also // searched the web and ran code, and the reply took 19 s instead of 7. + 'Answer a question about this pod from the pod itself; reach for the tools below ' diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 8f432d0ed2..f27ff1da65 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -45,6 +45,11 @@ import { import { hostedCaps, hostedCapsForOwner, isConfigured as hostedWorkerConfigured, listHostedInstallationsForUser, ownerIsPro, } from './hostedRuntimeService'; +import { + buildByoHandoffPayload, + defaultByoHandoffName, + type ByoHandoffAdapter, +} from './byoHandoffService'; // --- public surface -------------------------------------------------------- @@ -393,6 +398,39 @@ export const TOOLS = [ }, }, }, + { + type: 'function', + function: { + name: 'commonly_offer_local_agent', + description: + 'When someone asks you to inspect or change code, files, or a repository on their own computer, ' + + 'you cannot reach that computer from a hosted turn. Ask which local tool they use (Claude Code, ' + + 'Codex, OpenCode, or DeepSeek Harness) and which operating system if either is unknown. Once known, ' + + 'call this tool instead of posting shell directions yourself. It posts one handoff card with the ' + + 'exact install/login/attach/run commands for that OS and a Copy command button. The card contains ' + + 'no token. Do not also post a prose handoff about the commands.', + parameters: { + type: 'object', + properties: { + adapter: { + type: 'string', + enum: ['claude', 'codex', 'opencode', 'dsh'], + description: 'The local tool the person said they use.', + }, + os: { + type: 'string', + enum: ['mac', 'linux', 'windows'], + description: 'The operating system the person said they use.', + }, + name: { + type: 'string', + description: 'Optional local agent name; defaults to the person username plus the adapter.', + }, + }, + required: ['adapter', 'os'], + }, + }, + }, { type: 'function', function: { @@ -465,7 +503,8 @@ export const NATIVE_TOOL_NAMES: readonly string[] = TOOLS.map((tool) => tool.fun // sandbox key on the instance, web search (TASK-267a), which needs a search // key, and reading a page (TASK-267b). const OPT_IN_TOOLS: ReadonlySet = new Set([ - 'commonly_propose_action', 'commonly_offer_hosted_agent', 'commonly_run_code', 'commonly_web_search', + 'commonly_propose_action', 'commonly_offer_hosted_agent', 'commonly_offer_local_agent', + 'commonly_run_code', 'commonly_web_search', 'commonly_fetch_url', ]); const toolAvailable = (name: string): boolean => { @@ -762,6 +801,51 @@ async function dispatchTool( return { content: { ok: true, posted: true, result } }; } + case 'commonly_offer_local_agent': { + const adapter = String(args.adapter || '') as ByoHandoffAdapter; + let owner: { username?: string } | null = null; + if (ctx.installation?.installedBy) { + try { + owner = await (require('../models/User')).findById(ctx.installation.installedBy) + .select('username').lean() as { username?: string } | null; + } catch { + // A username is only a display-name seed. The handoff remains + // actionable with the stable "my-" fallback if the + // account lookup is transiently unavailable. + } + } + const agentName = args.name + ? String(args.name) + : defaultByoHandoffName(owner?.username, adapter); + const payload = buildByoHandoffPayload({ + adapter, + os: args.os, + podId: ctx.podId, + agentName, + }); + if ('error' in payload) { + return { content: { ok: false, error: payload.error }, error: 'handoff_invalid' }; + } + const AgentMessageService = require('./agentMessageService'); + const result = await AgentMessageService.postMessage({ + agentName: ctx.agentName, + instanceId: ctx.instanceId, + podId: ctx.podId, + // The structured payload is the handoff. This fixed content is a + // compatibility value for message storage and is not rendered as + // the instructions by the card. + content: 'Connect a local agent with the command below.', + messageType: 'card', + displayName: ctx.displayName, + installationConfig: ctx.installationConfig, + metadata: { source: 'native-runtime', handoff: 'byo' }, + payload, + noOutboundRelay: Boolean(ctx.webTainted), + answeringMessageId: ctx.answeringMessageId, + }); + return { content: { ok: true, posted: true, result } }; + } + case 'commonly_create_task': { const title = String(args.title || '').trim(); if (!title) { @@ -1990,6 +2074,9 @@ export async function runAgent( ? String((parsedArgs as any).content) : undefined; } + if (toolName === 'commonly_offer_local_agent' && !result.error) { + postedViaTool = true; + } messages.push({ role: 'tool', tool_call_id: tc.id, diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index f5139a06df..223da37905 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -41,6 +41,7 @@ const OPAQUE_PLACEHOLDERS = [ 'name', 'second', 'first', 'handle', 'author', 'affiliations', 'username', 'code', 'label', 'taskId', 'agent', 'machine', 'time', 'section', 'platform', 'projects', 'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents', 'os', + 'adapter', ]; // The exception runs the safe way: a name that is usually opaque and is a number diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c777e1d3d8..be6d601a63 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1315,6 +1315,25 @@ "failed": "Couldn't record the decision. Try again." } }, + "byoHandoff": { + "badge": "Local access", + "title": "Connect your own agent", + "body": "This hosted agent cannot reach your computer. Run the commands below to let your {{adapter}} work in this pod.", + "osLabel": "Computer", + "osMac": "macOS", + "osLinux": "Linux", + "osWindows": "Windows", + "copy": "Copy command", + "copied": "Copied!", + "availableOn": "This adapter is currently verified on macOS.", + "setupGuide": "{{adapter}} setup guide", + "adapters": { + "claude": "Claude Code", + "codex": "Codex", + "opencode": "OpenCode", + "dsh": "DeepSeek Harness" + } + }, "agentByo": { "eyebrow": "Your own agent", "title": "Bring your own agent", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 3a42064e13..c24b96c99e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1307,6 +1307,25 @@ "failed": "决定未能保存,请重试。" } }, + "byoHandoff": { + "badge": "本地接入", + "title": "接入你自己的智能体", + "body": "这个托管智能体无法访问你的电脑。运行下面的命令,让你的 {{adapter}} 在这个 Pod 中工作。", + "osLabel": "电脑", + "osMac": "macOS", + "osLinux": "Linux", + "osWindows": "Windows", + "copy": "复制命令", + "copied": "已复制!", + "availableOn": "此适配器目前已在 macOS 上验证。", + "setupGuide": "{{adapter}} 设置指南", + "adapters": { + "claude": "Claude Code", + "codex": "Codex", + "opencode": "OpenCode", + "dsh": "DeepSeek Harness" + } + }, "compare": { "kicker": "对比", "title": "Commonly 与其他选择", diff --git a/frontend/src/v2/__tests__/V2BYOHandoffCard.test.tsx b/frontend/src/v2/__tests__/V2BYOHandoffCard.test.tsx new file mode 100644 index 0000000000..b54a33862c --- /dev/null +++ b/frontend/src/v2/__tests__/V2BYOHandoffCard.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import fs from 'fs'; +import path from 'path'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2MessageRow from '../components/V2MessageRow'; +import type { V2Message } from '../hooks/useV2PodDetail'; + +jest.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ currentUser: { username: 'viewer' } }), +})); + +const command = [ + 'npm i -g @commonlyai/cli@latest', + 'commonly login', + 'commonly agent attach opencode --pod pod-1 --name sam-opencode --env /Users/you/agents/environment.json', + 'commonly agent run sam-opencode', +].join('\n'); +const message = (commands: Record): V2Message => ({ + id: 'handoff-1', + pod_id: 'pod-1', + user_id: 'scout-1', + content: 'Connect a local agent with the command below.', + message_type: 'card', + created_at: '2026-10-22T00:00:00.000Z', + user: { username: 'scout', isBot: true }, + payload: { + kind: 'byo-handoff', + adapter: 'opencode', + agentName: 'sam-opencode', + podId: 'pod-1', + os: 'mac', + commands, + supportedOs: ['mac'], + }, +}); + +describe('V2BYOHandoffCard', () => { + const writeText = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + writeText.mockClear(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + }); + + it('points its adapter guides at checked-in documents', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../components/V2BYOHandoffCard.tsx'), + 'utf8', + ); + const repoRoot = path.resolve(__dirname, '../../../..'); + for (const doc of ['docs/cli/opencode.md', 'docs/cli/dsh.md']) { + expect(source).toContain(`blob/main/${doc}`); + expect(fs.existsSync(path.join(repoRoot, doc))).toBe(true); + } + }); + + it('renders the server command and copies the exact same bytes', async () => { + render( + + + , + ); + + expect(screen.getByTestId('byo-handoff-card')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'OpenCode setup guide' })).toHaveAttribute( + 'href', + 'https://github.com/Team-Commonly/commonly/blob/main/docs/cli/opencode.md', + ); + expect(screen.getByTestId('byo-handoff-command').textContent).toBe(command); + fireEvent.click(screen.getByTestId('byo-handoff-copy')); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(command)); + expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument(); + }); + + it('offers every supported OS without changing the command text', () => { + const linux = [ + 'npm i -g @commonlyai/cli@latest', + 'commonly login', + 'commonly agent attach codex --pod pod-1 --name sam-codex', + 'commonly agent run sam-codex', + ].join('\n'); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Linux' })); + expect(screen.getByTestId('byo-handoff-command').textContent).toBe(linux); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index b77840c69e..178f0ed521 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1635,6 +1635,15 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(pitch).toMatch(/font-size: 13px/); }); + test('the hosted-to-BYO handoff preserves the server command and has a copy action', () => { + // The payload is the source of truth: the renderer must branch on the + // structured handoff kind, and the pre block must preserve line breaks so + // the copied bytes match the command shown in Connect. + expect(messageRow).toContain("message.payload?.kind === 'byo-handoff'"); + expect(ruleBody(v2, '.v2-byo-handoff__command')).toContain('white-space: pre-wrap'); + expect(ruleBody(v2, '.v2-root button.v2-byo-handoff__copy')).toContain('cursor: pointer'); + }); + test('retired chat selectors stay deleted', () => { for (const selector of [ '.v2-chat__btn--accent', diff --git a/frontend/src/v2/components/V2BYOHandoffCard.tsx b/frontend/src/v2/components/V2BYOHandoffCard.tsx new file mode 100644 index 0000000000..8972165a47 --- /dev/null +++ b/frontend/src/v2/components/V2BYOHandoffCard.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { V2Message } from '../hooks/useV2PodDetail'; + +type HandoffOs = 'mac' | 'linux' | 'windows'; +const OS_OPTIONS: Array<{ os: HandoffOs; label: string }> = [ + { os: 'mac', label: 'byoHandoff.osMac' }, + { os: 'linux', label: 'byoHandoff.osLinux' }, + { os: 'windows', label: 'byoHandoff.osWindows' }, +]; + +const ADAPTER_GUIDES: Record = { + opencode: 'https://github.com/Team-Commonly/commonly/blob/main/docs/cli/opencode.md', + dsh: 'https://github.com/Team-Commonly/commonly/blob/main/docs/cli/dsh.md', +}; + +const browserOs = (): HandoffOs => { + if (typeof navigator === 'undefined') return 'mac'; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform || nav.userAgent || ''; + if (/Windows/i.test(platform)) return 'windows'; + if (/X11|CrOS|Linux/i.test(platform) && !/Android/i.test(platform)) return 'linux'; + return 'mac'; +}; + +const validOs = (value: unknown): value is HandoffOs => ( + value === 'mac' || value === 'linux' || value === 'windows' +); + +interface V2BYOHandoffCardProps { + message: V2Message; + authorLabel: string; + time: string | null; +} + +const V2BYOHandoffCard: React.FC = ({ message, authorLabel, time }) => { + const { t } = useTranslation(); + const payload = message.payload || {}; + const supportedOs = (payload.supportedOs || OS_OPTIONS.map(({ os }) => os)) + .filter(validOs) + .filter((os, index, all) => all.indexOf(os) === index); + const availableOs = supportedOs.length > 0 ? supportedOs : ['mac' as HandoffOs]; + const requestedOs = validOs(payload.os) ? payload.os : undefined; + const initialOs = requestedOs && availableOs.includes(requestedOs) + ? requestedOs + : (availableOs.includes(browserOs()) ? browserOs() : availableOs[0]); + const [os, setOs] = useState(initialOs); + const [copied, setCopied] = useState(false); + const command = payload.commands?.[os] || payload.commands?.[availableOs[0]] || ''; + const adapter = payload.adapter || 'local'; + const adapterLabel = t(`byoHandoff.adapters.${adapter}`, { defaultValue: adapter }); + const adapterGuide = ADAPTER_GUIDES[adapter]; + + const copyCommand = async () => { + if (!command) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(command); + } else { + const input = document.createElement('textarea'); + input.value = command; + input.setAttribute('readonly', ''); + input.style.position = 'fixed'; + input.style.opacity = '0'; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + } + setCopied(true); + } catch { + setCopied(false); + } + }; + + return ( +
+
+
+ {t('byoHandoff.badge')} + {authorLabel} + {time && {time}} +
+
{t('byoHandoff.title')}
+
+ {t('byoHandoff.body', { adapter: adapterLabel })} +
+ {availableOs.length > 1 && ( +
+ {OS_OPTIONS.filter(({ os: option }) => availableOs.includes(option)).map(({ os: option, label }) => ( + + ))} +
+ )} +
{command}
+ + {availableOs.length === 1 && ( +
+ {t('byoHandoff.availableOn')} + {adapterGuide && ( + <> + {' '} + + {t('byoHandoff.setupGuide', { adapter: adapterLabel })} + + + )} +
+ )} +
+
+ ); +}; + +export default V2BYOHandoffCard; diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index 697542ed7f..d7cea411a9 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown'; import V2Avatar from './V2Avatar'; import V2GithubPrCard, { parseGithubPrUrls } from './V2GithubPrCard'; import V2ApprovalCard from './V2ApprovalCard'; +import V2BYOHandoffCard from './V2BYOHandoffCard'; import V2DecisionCard, { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard'; import V2MessageActions, { type V2RenderedReaction } from './V2MessageActions'; import { V2Message } from '../hooks/useV2PodDetail'; @@ -442,6 +443,9 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d if (message.payload?.kind === 'approval-card') { return ; } + if (message.payload?.kind === 'byo-handoff') { + return ; + } // §3.8 system event card. Detected by content shape (commonly-bot only // posts this exact form), so we don't depend on a metadata column the PG diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index f022064d1d..5de96c9674 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -72,7 +72,7 @@ export interface V2Message { users?: Array<{ id: string; username: string; displayName?: string }>; }>; // ADR-020 D3: structured component payload. `kind` discriminates renderers - // (approval-card first). Server-composed; survives normalizeMessage via + // (approval-card and byo-handoff). Server-composed; survives normalizeMessage via // the `...raw` spread — declared here so consumers can read it typed. payload?: { kind?: string; @@ -92,6 +92,14 @@ export interface V2Message { expiresAt?: string; executionResult?: { podId?: string; podName?: string } | Record; executionError?: string; + // kind 'byo-handoff': server-composed, token-free commands for a local + // runtime. The card copies commands[os] verbatim; it never rebuilds or + // interpolates a shell command in the browser. + adapter?: 'claude' | 'codex' | 'opencode' | 'dsh' | string; + podId?: string; + os?: 'mac' | 'linux' | 'windows' | string; + commands?: Partial>; + supportedOs?: Array<'mac' | 'linux' | 'windows' | string>; // Raw tool-call arguments are intentionally absent from the shared card // payload. The owner fetches this field from /api/approvals/pending while // the action is flagged; the backend scopes it to ownerUserId. diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index b128002e9c..fab5028e1d 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -8959,6 +8959,117 @@ body.modern-ui.v2-canvas { color: #b91c1c; } +/* Hosted-to-BYO handoff: the command is server-composed and the card copies + the selected string verbatim. Keep it visually distinct from approval cards + without making the shell instructions look like ordinary agent prose. */ +.v2-byo-handoff { + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + background: var(--v2-surface); + box-shadow: 0 0 0 2px var(--v2-accent); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.v2-byo-handoff__head { + display: flex; + align-items: center; + gap: 8px; +} + +.v2-byo-handoff__badge { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--v2-ink); + font-family: var(--v2-font-mono); +} + +.v2-byo-handoff__agent, +.v2-byo-handoff__time { + font-size: 12.5px; + color: var(--v2-text-secondary); +} + +.v2-byo-handoff__time { + margin-left: auto; + font-size: 12px; + color: var(--v2-text-muted); +} + +.v2-byo-handoff__title { + font-size: 15px; + font-weight: 650; + color: var(--v2-text-primary); +} + +.v2-byo-handoff__body, +.v2-byo-handoff__note { + font-size: 13px; + color: var(--v2-text-secondary); + line-height: 1.45; +} + +.v2-byo-handoff__os { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.v2-root button.v2-byo-handoff__os-btn, +.v2-root button.v2-byo-handoff__copy { + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.v2-root button.v2-byo-handoff__os-btn { + padding: 6px 10px; + background: var(--v2-bg); + color: var(--v2-text-secondary); +} + +.v2-root button.v2-byo-handoff__os-btn--selected { + background: var(--v2-ink); + border-color: var(--v2-ink); + color: var(--v2-on-ink); +} + +.v2-byo-handoff__command { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius-sm); + background: var(--v2-bg); + color: var(--v2-text-primary); + font: 12px/1.5 var(--v2-font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow-x: auto; +} + +.v2-root button.v2-byo-handoff__copy { + align-self: flex-start; + padding: 7px 14px; + background: var(--v2-ink); + border-color: var(--v2-ink); + color: var(--v2-on-ink); +} + +.v2-root button.v2-byo-handoff__copy:hover:not(:disabled) { + background: var(--v2-ink-hover); +} + +.v2-root button.v2-byo-handoff__copy:disabled { + opacity: 0.55; + cursor: default; +} + /* Inspector pending-approvals index (durable, row-backed — cards in chat retire with message retention; this list never does). */ .v2-inspector__approvals {