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
47 changes: 47 additions & 0 deletions backend/__tests__/unit/routes/registry.hosted-cap-gate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
// Mirrors the harness in registry.cloud-entitlement-gate.test.js.
const mockCountHosted = jest.fn();
const mockListHosted = jest.fn();
const mockPodHasOutsiderWritableGrant = jest.fn();

jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: (...args) => mockPodHasOutsiderWritableGrant(...args),
}));

jest.mock('../../../models/AgentRegistry', () => ({
AgentRegistry: {
Expand Down Expand Up @@ -38,12 +43,17 @@ jest.mock('../../../services/hostedRuntimeService', () => ({
hostedCaps: () => ({ agentsPerUser: 1, turnsPerDay: 200 }),
countHostedAgentsForUser: (...args) => mockCountHosted(...args),
listHostedInstallationsForUser: (...args) => mockListHosted(...args),
readRuntimeConfig: (installation) => installation?.config?.runtime || {},
isHostedInstallation: (installation) => (
String(installation?.config?.runtime?.runtimeType || '').trim().toLowerCase() === 'hosted'
),
}));

const { AgentRegistry, AgentInstallation } = require('../../../models/AgentRegistry');
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
const AgentProfile = require('../../../models/AgentProfile');
const AgentTemplate = require('../../../models/AgentTemplate');
const Activity = require('../../../models/Activity');
const installRouter = require('../../../routes/registry/install');

Expand Down Expand Up @@ -79,6 +89,7 @@ describe('registry install — hosted runtime cap gate', () => {

beforeEach(() => {
jest.clearAllMocks();
mockPodHasOutsiderWritableGrant.mockResolvedValue(false);
mockListHosted.mockResolvedValue([]);
Pod.findById.mockReturnValue(buildLeanChain({
_id: 'pod-1', createdBy: 'user-1', members: ['user-1'], type: 'chat',
Expand All @@ -101,6 +112,7 @@ describe('registry install — hosted runtime cap gate', () => {
}));
User.findOne.mockImplementation(() => buildSelectLeanChain(null));
User.findById.mockReturnValue(buildSelectLeanChain({ username: 'installer', role: 'user' }));
AgentTemplate.find.mockReturnValue(buildSelectLeanChain([]));
AgentProfile.findOneAndUpdate.mockResolvedValue(true);
Activity.create.mockResolvedValue(true);
});
Expand Down Expand Up @@ -194,4 +206,39 @@ describe('registry install — hosted runtime cap gate', () => {
expect(hosted).toEqual({ engine: 'native', provisionedAt: expect.any(Date), about: 'Keeps the research log.' });
});
});

describe('hosted installs into a pod with an outsider-writable grant', () => {
afterEach(() => { delete process.env.HOSTED_ENGINE; });

it('lets the native engine join, using the same engine written to the installation', async () => {
process.env.HOSTED_ENGINE = 'native';
mockPodHasOutsiderWritableGrant.mockResolvedValue(true);
mockCountHosted.mockResolvedValue(0);
const req = makeReq('hosted');
req.body.config.hosted = { engine: 'durable-object' };
const res = makeRes();
await installHandler(req, res);
expect(res.status).not.toHaveBeenCalledWith(403);
expect(AgentInstallation.install).toHaveBeenCalledWith('my-agent', 'pod-1', expect.objectContaining({
config: expect.objectContaining({ hosted: expect.objectContaining({ engine: 'native' }) }),
}));
expect(require('../../../services/agentIdentityService').ensureAgentInPod)
.toHaveBeenCalledWith(expect.anything(), 'pod-1', 'hosted', 'native');
});

it('refuses the Durable Object before any installation or membership write', async () => {
process.env.HOSTED_ENGINE = 'durable-object';
mockPodHasOutsiderWritableGrant.mockResolvedValue(true);
const res = makeRes();
const req = makeReq('hosted');
req.body.config.hosted = { engine: 'native' };
await installHandler(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
code: 'outsider_grant_non_native_agent_join',
}));
expect(AgentInstallation.install).not.toHaveBeenCalled();
expect(require('../../../services/agentIdentityService').ensureAgentInPod).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,22 @@ describe('auto-join into a pod holding an outsider-writable grant', () => {
}),
);
});

it('copies a hosted-native Map declaration but refuses a Durable Object source', async () => {
const source = (engine) => ({
agentName: 'hosted-agent', instanceId: 'default', podId: 'source-pod',
config: new Map([['runtime', { runtimeType: 'hosted' }], ['hosted', { engine }]]),
});
AgentInstallation.find.mockReturnValueOnce(chain([source('native')]))
.mockReturnValueOnce(chain([source('durable-object')]));
expect(await AgentAutoJoinService.runAutoJoinAgentOwnedPods()).toEqual(expect.objectContaining({ installed: 1 }));
expect(AgentInstallation.install).toHaveBeenCalledWith(
'hosted-agent', 'target-pod', expect.objectContaining({
config: expect.objectContaining({ hosted: { engine: 'native' } }),
}),
);
AgentInstallation.install.mockClear();
expect(await AgentAutoJoinService.runAutoJoinAgentOwnedPods()).toEqual(expect.objectContaining({ skipped: 1 }));
expect(AgentInstallation.install).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ describe('AgentIdentityService.ensureAgentInPod — agent-room 1:1 invariant', (
expect(pod.members).toEqual(['human', 'bot']);
});

it('joins a hosted-native bot but refuses a Durable Object bot before membership changes', async () => {
podHasOutsiderWritableGrant.mockResolvedValue(true);
const pod = { _id: 'p-chat', type: 'chat', members: ['human'], save: jest.fn() };
Pod.findById.mockResolvedValue(pod);
await expect(AgentIdentityService.ensureAgentInPod({ _id: 'hosted-native' }, 'p-chat', 'hosted', 'native'))
.resolves.toBe(pod);
expect(pod.members).toEqual(['human', 'hosted-native']);
await expect(AgentIdentityService.ensureAgentInPod({ _id: 'hosted-do' }, 'p-chat', 'hosted', 'durable-object'))
.rejects.toHaveProperty('code', 'outsider_grant_non_native_agent_join');
expect(pod.members).toEqual(['human', 'hosted-native']);
});

it('refuses to add a third agent to an existing agent-room', async () => {
const pod = {
_id: 'p-room',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ describe('outsider-writable grant agent join policy', () => {
await expect(assertAgentRuntimeMayJoinPod('pod-1', 'opencode')).resolves.toBeUndefined();
});

it('allows hosted on the native engine but refuses hosted on the Durable Object', async () => {
await expect(assertAgentRuntimeMayJoinPod('pod-1', 'hosted', 'native')).resolves.toBeUndefined();
expect(podHasOutsiderWritableGrant).not.toHaveBeenCalled();
await expect(assertAgentRuntimeMayJoinPod('pod-1', 'hosted', 'durable-object')).rejects.toHaveProperty(
'code', 'outsider_grant_non_native_agent_join',
);
await expect(assertAgentRuntimeMayJoinPod('pod-1', 'hosted')).rejects.toHaveProperty(
'code', 'outsider_grant_non_native_agent_join',
);
});

it('requires a live native installation for an undeclared membership writer', async () => {
AgentInstallation.findOne.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({
Expand All @@ -47,6 +58,22 @@ describe('outsider-writable grant agent join policy', () => {
expect(runtimeTypeFromConfig(new Map([['runtime', { runtimeType: 'native' }]]))).toBe('native');
});

it('allows an installed hosted-native agent but refuses an installed Durable Object agent', async () => {
const lean = jest.fn()
.mockResolvedValueOnce({ config: new Map([
['runtime', { runtimeType: 'hosted' }], ['hosted', { engine: 'native' }],
]) })
.mockResolvedValueOnce({ config: new Map([
['runtime', { runtimeType: 'hosted' }], ['hosted', { engine: 'durable-object' }],
]) });
AgentInstallation.findOne.mockReturnValue({ select: jest.fn().mockReturnValue({ lean }) });
await expect(assertInstalledAgentMayJoinPod('pod-1', 'bot', 'default')).resolves.toBeUndefined();
await expect(assertInstalledAgentMayJoinPod('pod-1', 'bot', 'default')).rejects.toHaveProperty(
'code', 'outsider_grant_non_native_agent_join',
);
expect(AgentInstallation.findOne.mock.results[0].value.select).toHaveBeenCalledWith('config');
});

it('never puts a caller-controlled query object in the installation lookup', async () => {
AgentInstallation.findOne.mockReturnValue({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }),
Expand Down
9 changes: 7 additions & 2 deletions backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {
assertAgentRuntimeMayJoinPod,
runtimeTypeFromConfig,
} = require('../services/outsiderGrantAgentJoinPolicy');
const { hostedEngineOf } = require('../services/hostedEngine');

// ADR-003 Phase 4: per-token rate limiter for the cross-agent surface.
// Token-global (covers any pod the token is valid for). Complementary to the
Expand Down Expand Up @@ -3149,7 +3150,9 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an
const sourceConfig = sourceInstall?.config instanceof Map
? Object.fromEntries(sourceInstall.config) : sourceInstall?.config || {};
try {
await assertAgentRuntimeMayJoinPod(existingPod._id, runtimeTypeFromConfig(sourceConfig));
await assertAgentRuntimeMayJoinPod(
existingPod._id, runtimeTypeFromConfig(sourceConfig), hostedEngineOf({ config: sourceConfig }),
);
} catch (joinError: any) {
if (joinError?.code === 'outsider_grant_non_native_agent_join') {
return res.status(403).json({ code: joinError.code, message: joinError.message });
Expand Down Expand Up @@ -3309,7 +3312,9 @@ router.post('/pods/:podId/self-install', phase4RateLimit, agentRuntimeAuth, asyn
const sourceConfig = sourceInstall.config instanceof Map
? Object.fromEntries(sourceInstall.config) : sourceInstall.config || {};
try {
await assertAgentRuntimeMayJoinPod(podId, runtimeTypeFromConfig(sourceConfig));
await assertAgentRuntimeMayJoinPod(
podId, runtimeTypeFromConfig(sourceConfig), hostedEngineOf({ config: sourceConfig }),
);
} catch (joinError: any) {
if (joinError?.code === 'outsider_grant_non_native_agent_join') {
return res.status(403).json({ code: joinError.code, message: joinError.message });
Expand Down
6 changes: 5 additions & 1 deletion backend/routes/registry/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecVa
const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership');
const { configWriteRefusal, runtimeKeepingType } = require('../../services/configWriteGuard');
const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy');
const { hostedEngineOf } = require('../../services/hostedEngine');

const agentConfigRouter = express.Router();

Expand Down Expand Up @@ -271,7 +272,10 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res:
const stored = normalizeConfigMap(targetInstallation.config) || {};
const runtime = config?.runtime !== undefined ? config.runtime : stored.runtime;
// eslint-disable-next-line no-await-in-loop
await assertAgentRuntimeMayJoinPod(targetInstallation.podId, runtime?.runtimeType);
await assertAgentRuntimeMayJoinPod(
targetInstallation.podId, runtime?.runtimeType,
hostedEngineOf({ config: stored }),
);
}
} catch (joinError: any) {
if (joinError?.code === 'outsider_grant_non_native_agent_join') {
Expand Down
8 changes: 5 additions & 3 deletions backend/routes/registry/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,8 +463,10 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any
// even when the caller omits an explicit runtimeType.
const effectiveRuntimeType = String(runtimeConfig.runtimeType || '').trim().toLowerCase()
|| String(AgentIdentityService.getAgentTypeConfig(safeAgentName)?.runtime || '').trim().toLowerCase();
const newHostedEngine = effectiveRuntimeType === HostedRuntime.HOSTED_RUNTIME_TYPE
? HostedEngine.engineForNewHostedInstall() : undefined;
try {
await assertAgentRuntimeMayJoinPod(podId, effectiveRuntimeType);
await assertAgentRuntimeMayJoinPod(podId, effectiveRuntimeType, newHostedEngine);
} catch (joinError: any) {
if (joinError?.code === 'outsider_grant_non_native_agent_join') {
return res.status(403).json({ code: joinError.code, message: joinError.message });
Expand Down Expand Up @@ -515,7 +517,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any
// would let a browser pick its own engine or claim to be provisioned.
if (effectiveRuntimeType === HostedRuntime.HOSTED_RUNTIME_TYPE) {
delete installConfig.hosted;
if (HostedEngine.engineForNewHostedInstall() === HostedEngine.HOSTED_ENGINE_NATIVE) {
if (newHostedEngine === HostedEngine.HOSTED_ENGINE_NATIVE) {
installConfig.hosted = HostedEngine.nativeHostedConfigForInstall(explicitDescription || undefined);
}
}
Expand Down Expand Up @@ -650,7 +652,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any
...(explicitDescription ? { description: explicitDescription } : {}),
...(avatarSeed ? { profilePicture: avatarSeed } : {}),
});
await AgentIdentityService.ensureAgentInPod(agentUser, podId, effectiveRuntimeType);
await AgentIdentityService.ensureAgentInPod(agentUser, podId, effectiveRuntimeType, newHostedEngine);
} catch (identityError: unknown) {
// A refusal is not a best-effort failure (TASK-133 b, wren 73987/73988):
// swallowing it would answer 2xx and leave an AgentInstallation row with
Expand Down
3 changes: 2 additions & 1 deletion backend/routes/registry/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const User = require('../../models/User');
const Integration = require('../../models/Integration');
const AgentIdentityService = require('../../services/agentIdentityService');
const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy');
const { hostedEngineOf } = require('../../services/hostedEngine');
const DMService = require('../../services/dmService');
const {
provisionAgentRuntime,
Expand Down Expand Up @@ -155,7 +156,7 @@ provisionRouter.post('/pods/:podId/agents/:name/provision', provisionRateLimit,
return res.status(400).json({ error: 'Unknown agent runtime type' });
}
try {
await assertAgentRuntimeMayJoinPod(podId, runtimeType);
await assertAgentRuntimeMayJoinPod(podId, runtimeType, hostedEngineOf(installation));
} catch (joinError: any) {
if (joinError?.code === 'outsider_grant_non_native_agent_join') {
return res.status(403).json({ code: joinError.code, message: joinError.message });
Expand Down
3 changes: 2 additions & 1 deletion backend/routes/registry/reprovision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const AgentProfile = require('../../models/AgentProfile');
const Integration = require('../../models/Integration');
const AgentIdentityService = require('../../services/agentIdentityService');
const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy');
const { hostedEngineOf } = require('../../services/hostedEngine');
const {
provisionAgentRuntime,
startAgentRuntime,
Expand Down Expand Up @@ -56,7 +57,7 @@ const reprovisionInstallation = async ({
if (!runtimeType) {
throw new Error('Unknown agent runtime type');
}
await assertAgentRuntimeMayJoinPod(podId, runtimeType);
await assertAgentRuntimeMayJoinPod(podId, runtimeType, hostedEngineOf(installation));

const agentUser = await AgentIdentityService.getOrCreateAgentUser(name, {
instanceId: normalizedInstanceId,
Expand Down
27 changes: 18 additions & 9 deletions backend/services/agentAutoJoinService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Activity from '../models/Activity';
import { AgentInstallation } from '../models/AgentRegistry';
import AgentIdentityService from './agentIdentityService';
import { assertAgentRuntimeMayJoinPod, runtimeTypeFromConfig } from './outsiderGrantAgentJoinPolicy';
import { hostedEngineOf } from './hostedEngine';

const MAX_TOTAL_INSTALLS = Math.max(1, parseInt(process.env.AGENT_AUTO_JOIN_MAX_TOTAL || '200', 10));
const MAX_PER_SOURCE = Math.max(1, parseInt(process.env.AGENT_AUTO_JOIN_MAX_PER_SOURCE || '25', 10));
Expand Down Expand Up @@ -76,16 +77,24 @@ class AgentAutoJoinService {

try {
// eslint-disable-next-line no-await-in-loop
await assertAgentRuntimeMayJoinPod((pod as Record<string, unknown>)._id, runtimeTypeFromConfig(sourceInstall.config));
await assertAgentRuntimeMayJoinPod(
(pod as Record<string, unknown>)._id,
runtimeTypeFromConfig(sourceInstall.config),
hostedEngineOf(sourceInstall),
);
// eslint-disable-next-line no-await-in-loop
await AgentInstallation.install(String(sourceInstall.agentName), (pod as { _id: import('mongoose').Types.ObjectId })._id, {
version: String(sourceInstall.version || '1.0.0'),
config: mergedConfig as unknown as Map<string, unknown>,
scopes: Array.isArray(sourceInstall.scopes) ? sourceInstall.scopes as string[] : [],
installedBy: agentUser._id,
instanceId,
displayName: String(sourceInstall.displayName || ''),
});
await AgentInstallation.install(
String(sourceInstall.agentName),
(pod as { _id: import('mongoose').Types.ObjectId })._id,
{
version: String(sourceInstall.version || '1.0.0'),
config: mergedConfig as unknown as Map<string, unknown>,
scopes: Array.isArray(sourceInstall.scopes) ? sourceInstall.scopes as string[] : [],
installedBy: agentUser._id,
instanceId,
displayName: String(sourceInstall.displayName || ''),
},
);
// eslint-disable-next-line no-await-in-loop
await AgentIdentityService.ensureAgentInPod(agentUser, (pod as Record<string, unknown>)._id);
try {
Expand Down
5 changes: 3 additions & 2 deletions backend/services/agentIdentityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,8 @@ class AgentIdentityService {
}

static async ensureAgentInPod(
agentUser: InstanceType<typeof User>, podId: unknown, intendedRuntimeType?: string,
agentUser: InstanceType<typeof User>, podId: unknown,
intendedRuntimeType?: string, intendedHostedEngine?: string,
): Promise<InstanceType<typeof Pod> | null> {
if (!agentUser || !podId) return null;
const pod = await Pod.findById(podId);
Expand Down Expand Up @@ -693,7 +694,7 @@ class AgentIdentityService {
return null;
}
if (intendedRuntimeType) {
await assertAgentRuntimeMayJoinPod(podId, intendedRuntimeType);
await assertAgentRuntimeMayJoinPod(podId, intendedRuntimeType, intendedHostedEngine);
} else {
await assertInstalledAgentMayJoinPod(
podId, agentUser.botMetadata?.agentName, agentUser.botMetadata?.instanceId,
Expand Down
8 changes: 7 additions & 1 deletion backend/services/hostedEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*
* Every site that has to tell the two engines apart asks this module.
*/
import { hostedCaps, isHostedInstallation } from './hostedRuntimeService';
import { hostedCaps, isHostedInstallation, readRuntimeConfig } from './hostedRuntimeService';

export const HOSTED_ENGINE_NATIVE = 'native';
export const HOSTED_ENGINE_DURABLE_OBJECT = 'durable-object';
Expand All @@ -37,6 +37,12 @@ export const hostedEngineOf = (installation: any): HostedEngine => (
: HOSTED_ENGINE_DURABLE_OBJECT
);

/** The engine boundary shared by grant eligibility and agent-join checks. */
export const runsInNarrowedProcess = (installation: any): boolean => (
String(readRuntimeConfig(installation).runtimeType || '').trim().toLowerCase() === 'native'
|| (isHostedInstallation(installation) && hostedEngineOf(installation) === HOSTED_ENGINE_NATIVE)
);

/**
* The engine a NEW hosted install lands on. `HOSTED_ENGINE=native` opts the
* instance in; anything else keeps the Durable Object. Existing installs keep
Expand Down
Loading
Loading