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
20 changes: 20 additions & 0 deletions backend/__tests__/unit/routes/hosted.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ jest.mock('../../../services/nativeRuntimeService', () => ({
}));
const mockMarkByoOpened = jest.fn();
jest.mock('../../../services/onboardingMarks', () => ({ markByoOpened: (...args) => mockMarkByoOpened(...args) }));
const mockCompleteConnectAgent = jest.fn();
jest.mock('../../../services/starterTaskService', () => ({
completeConnectAgentStarterTask: (...args) => mockCompleteConnectAgent(...args),
}));
jest.mock('../../../routes/registry/tokens', () => ({
revokeRuntimeTokensForAgent: (...args) => mockRevokeRuntimeTokens(...args),
issueRuntimeTokenForAgent: (...args) => mockIssueToken(...args),
Expand Down Expand Up @@ -285,6 +289,9 @@ describe('/api/hosted', () => {
});
expect(mockHosted.provisionAgent).toHaveBeenCalledWith({ agentName: 'scout', instanceId: 'demo', runtimeToken: 'cm_agent_secret' });
expect(installation.config.get('hosted').provisionedAt).toBeInstanceOf(Date);
// The worker's first use of its token completes the onboarding card, as a
// BYO agent's does; provision does not claim it is listening yet.
expect(mockCompleteConnectAgent).not.toHaveBeenCalled();
});

it('rotates: revokes the old credential rows, clears the stored hashes, mints fresh', async () => {
Expand Down Expand Up @@ -467,6 +474,19 @@ describe('/api/hosted on the native engine', () => {
expect(JSON.stringify(res.body)).not.toMatch(/cm_agent_/);
});

// QA 2026-10-16: "Connect your first agent" stayed pending after a hosted
// agent went live, because the card completes on a runtime token's first
// use and a native hosted agent has no token. Provision is its live moment.
it('provision completes the "Connect your first agent" card in the agent\'s pod', async () => {
mockFindOne.mockResolvedValue(nativeInstallation());
const res = await request(app).post('/api/hosted/provision').send({ agentName: 'scout' });
expect(res.status).toBe(200);
expect(mockCompleteConnectAgent).toHaveBeenCalledTimes(1);
const [{ podIds, agentLabel }] = mockCompleteConnectAgent.mock.calls[0];
expect(podIds.map(String)).toEqual(['pod-1']);
expect(agentLabel).toBeTruthy();
});

it('provision of a native install 503s when the native runtime is unavailable, even with the worker up', async () => {
mockHosted.isConfigured.mockReturnValue(true);
mockNativeAvailable.mockReturnValue(false);
Expand Down
9 changes: 9 additions & 0 deletions backend/routes/hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const auth = require('../middleware/auth');
const { AgentInstallation } = require('../models/AgentRegistry');
const User = require('../models/User');
const AgentIdentityService = require('../services/agentIdentityService');
const { completeConnectAgentStarterTask } = require('../services/starterTaskService');
const hostedRuntime = require('../services/hostedRuntimeService');
const { isNativeRuntimeAvailable } = require('../services/nativeRuntimeService');
const {
Expand Down Expand Up @@ -258,6 +259,14 @@ router.post('/provision', hostedRateLimit, auth, async (req: any, res: any) => {
{ _id: installation._id },
{ $set: { 'config.hosted.provisionedAt': hosted.provisionedAt || new Date() } },
);
// Live from here, and with no runtime token there is no first use to
// complete "Connect your first agent" the way a BYO or Durable Object
// agent's does (starterTaskService). QA 2026-10-16: the card sat pending
// while the hosted agent answered. Never throws, never awaited.
void completeConnectAgentStarterTask({
podIds: [installation.podId],
agentLabel: agentUser.botMetadata?.displayName || installation.displayName || agentName,
});
return res.json({
provisioned: true,
engine,
Expand Down
6 changes: 4 additions & 2 deletions backend/services/starterTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
* starter task done" while the card sat in Pending forever.
*
* Caller contract: fire-and-forget from the token-auth first-use paths
* (agentRuntimeAuth HTTP + agentWebSocketService WS). Never throws; a board
* write must not be able to fail an auth request.
* (agentRuntimeAuth HTTP + agentWebSocketService WS), and from the native
* hosted provision (routes/hosted.ts), where no token exists to be first-used
* and the agent is live once provisioned. Never throws; a board write must not
* be able to fail an auth or provision request.
*/
import mongoose from 'mongoose';
import Task from '../models/Task';
Expand Down
Loading