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
72 changes: 72 additions & 0 deletions backend/__tests__/unit/services/byoHandoffService.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
126 changes: 126 additions & 0 deletions backend/services/byoHandoffService.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ByoHandoffOs, string>>;
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<Record<ByoHandoffOs, string>> = {};
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}`;
};
7 changes: 7 additions & 0 deletions backend/services/hostedEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 '
Expand Down
Loading
Loading