diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index d7a8d225bb..6b257568e2 100644 --- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js +++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js @@ -154,6 +154,54 @@ describe('placement request', () => { expect(res.status).toBe(403); }); + // C14 rehearsal follow-up (sprint-review on #2365): "On my computer" with + // the name of the person's hosted agent stamped a placement the daemon's + // runtime-token mint then refused, so the page said "requested" and nothing + // ran. Refused up front with the mint's own code, and nothing is stamped. + describe('an agent Commonly runs in-process cannot be placed on a machine', () => { + const runInProcess = (config) => AgentInstallation.updateOne( + { agentName: 'wren-test', instanceId: 'default' }, + { $set: { config } }, + ); + + it.each([ + ['hosted on the native engine', { runtime: { runtimeType: 'hosted' }, hosted: { engine: 'native' } }], + ['native', { runtime: { runtimeType: 'native' } }], + ])('%s: 409 in_process_agent_no_token, and no request is stamped', async (_label, config) => { + await runInProcess(config); + const res = await requestPlacement('machine-a'); + expect(res.status).toBe(409); + expect(res.body.code).toBe('in_process_agent_no_token'); + expect(res.body.message).toMatch(/^wren-test is your hosted agent/); + const identity = await User.findById(bot._id).lean(); + expect(identity.botMetadata.requestedMachineId ?? null).toBeNull(); + const seenByA = await assigned(DAEMON_A); + expect(seenByA.body.agents).toEqual([]); + }); + + it('a request stamped before the check can still be withdrawn', async () => { + await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.requestedMachineId': 'machine-a' } }); + await runInProcess({ runtime: { runtimeType: 'native' } }); + const res = await requestPlacement(null); + expect(res.status).toBe(200); + const identity = await User.findById(bot._id).lean(); + expect(identity.botMetadata.requestedMachineId).toBeNull(); + }); + + it('ownership is checked first: another user learns nothing about how it runs', async () => { + await runInProcess({ runtime: { runtimeType: 'native' } }); + global.__CALLER_ID = String(new mongoose.Types.ObjectId()); + const res = await requestPlacement('machine-a'); + expect(res.status).toBe(403); + expect(res.body.code).not.toBe('in_process_agent_no_token'); + }); + + it('an agent the person runs themself is still placed', async () => { + const res = await requestPlacement('machine-a'); + expect(res.status).toBe(200); + }); + }); + it('409s when the agent is bound to a different machine', async () => { await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-b' } }); const res = await requestPlacement('machine-a'); diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index 866ec33902..f7ecfa6aa0 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -29,6 +29,7 @@ const User = require('../models/User'); const { getRuntimeTokenHashesForAgent, revokeRuntimeTokensForAgent, + runsInProcess, } = require('./registry/tokens'); const router = express.Router(); @@ -216,6 +217,20 @@ router.post('/request', bindingRateLimit, auth, async (req: express.Request & { }); } + // An agent Commonly runs in-process (the person's hosted agent) can't also + // run on a machine: its daemon would be refused at the runtime-token mint, + // so the page said "requested" and nothing ever ran. Refused here, with the + // mint's code, after the ownership check so another user's agent never + // reveals how it runs. Withdrawing (machineId: null) stays allowed, so a + // request stamped before this check can still be cleared. + if (requestedMachineId && await runsInProcess(null, { agentName, instanceId })) { + return res.status(409).json({ + code: 'in_process_agent_no_token', + message: `${agentName} is your hosted agent, and Commonly runs it, so it can't also run on your computer. ` + + 'Choose a different name for an agent you run yourself.', + }); + } + if (requestedMachineId) { // eslint-disable-next-line @typescript-eslint/no-require-imports const Machine = require('../models/Machine'); diff --git a/backend/routes/registry/tokens.ts b/backend/routes/registry/tokens.ts index f0373e9617..39562953a0 100644 --- a/backend/routes/registry/tokens.ts +++ b/backend/routes/registry/tokens.ts @@ -433,6 +433,7 @@ module.exports = { revokeRuntimeTokensAfterLastUninstall, issueRuntimeTokenForAgent, InProcessAgentTokenRefused, + runsInProcess, issueRuntimeTokenForInstallation, issueUserTokenForInstallation, }; diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx index 53b4a548ad..41b4537c93 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -323,4 +323,39 @@ describe('BYO on-my-computer mode', () => { // route), so this asserts the exclusion is real rather than an empty set. expect(container.querySelectorAll('.v2-rail__account .v2-avatar').length).toBe(1); }); + + // The placement route refuses an agent Commonly runs in-process (the + // person's hosted agent) with in_process_agent_no_token. The page says so + // in plain words and shows no "waiting for your computer" result. Matched on + // the words both the server's message and the page's own copy for that code + // share, so it holds whichever one renders. + test("placing the person's hosted agent on a computer is refused in plain words", async () => { + mockGet(); + axios.post.mockImplementation((url) => { + if (url === '/api/registry/install') { + return Promise.reject({ response: { status: 400, data: { error: 'Agent already installed in this pod' } } }); + } + if (url === '/api/agent-binding/request') { + return Promise.reject({ + response: { + status: 409, + data: { + code: 'in_process_agent_no_token', + message: "sam-hosted is your hosted agent, and Commonly runs it, so it can't also run on your computer. Choose a different name for an agent you run yourself.", + }, + }, + }); + } + return Promise.resolve({ data: {} }); + }); + renderPage(); + await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-machine')); + fireEvent.change(screen.getByPlaceholderText('my-mcp-agent'), { target: { value: 'sam-hosted' } }); + fireEvent.click(screen.getByText('Add to this computer')); + + expect(await screen.findByText(/sam-hosted is your hosted agent/)).toBeInTheDocument(); + expect(screen.queryByTestId('byo-machine-result')).toBeNull(); + expect(screen.queryByText(/has no runtime token/)).toBeNull(); + }); });