From 830d8bff920cbb8c85aae85b574f6c756a39d004 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:50:25 -0700 Subject: [PATCH] fix(connectors): admit hosted-native agents to granted pods Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium --- .../routes/registry.hosted-cap-gate.test.js | 47 +++++++++++++++++++ ...agentAutoJoinService.outsiderGrant.test.js | 18 +++++++ ...ntIdentityService.ensureAgentInPod.test.js | 12 +++++ .../outsiderGrantAgentJoinPolicy.test.js | 27 +++++++++++ backend/routes/agentsRuntime.ts | 9 +++- backend/routes/registry/agent-config.ts | 6 ++- backend/routes/registry/install.ts | 8 ++-- backend/routes/registry/provision.ts | 3 +- backend/routes/registry/reprovision.ts | 3 +- backend/services/agentAutoJoinService.ts | 27 +++++++---- backend/services/agentIdentityService.ts | 5 +- backend/services/hostedEngine.ts | 8 +++- .../services/outsiderGrantAgentJoinPolicy.ts | 9 ++-- .../services/outsiderGrantPodEligibility.ts | 15 +----- 14 files changed, 161 insertions(+), 36 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.hosted-cap-gate.test.js b/backend/__tests__/unit/routes/registry.hosted-cap-gate.test.js index d815af9800..495270d508 100644 --- a/backend/__tests__/unit/routes/registry.hosted-cap-gate.test.js +++ b/backend/__tests__/unit/routes/registry.hosted-cap-gate.test.js @@ -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: { @@ -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'); @@ -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', @@ -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); }); @@ -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(); + }); + }); }); diff --git a/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js b/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js index 5db812bb29..e8143420e9 100644 --- a/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js +++ b/backend/__tests__/unit/services/agentAutoJoinService.outsiderGrant.test.js @@ -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(); + }); }); diff --git a/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js b/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js index ecb1a254cd..593525fdf8 100644 --- a/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js +++ b/backend/__tests__/unit/services/agentIdentityService.ensureAgentInPod.test.js @@ -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', diff --git a/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js b/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js index 93e33e3d06..539137f954 100644 --- a/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js +++ b/backend/__tests__/unit/services/outsiderGrantAgentJoinPolicy.test.js @@ -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({ @@ -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) }), diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 5e4f4976f8..9c731a1aae 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -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 @@ -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 }); @@ -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 }); diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index 64787a869f..7b3bfdbc35 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -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(); @@ -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') { diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index f665c46356..a436e8e0cf 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -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 }); @@ -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); } } @@ -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 diff --git a/backend/routes/registry/provision.ts b/backend/routes/registry/provision.ts index a1d3d9e63d..681caa243c 100644 --- a/backend/routes/registry/provision.ts +++ b/backend/routes/registry/provision.ts @@ -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, @@ -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 }); diff --git a/backend/routes/registry/reprovision.ts b/backend/routes/registry/reprovision.ts index 3bbc597b7a..4a913b5a13 100644 --- a/backend/routes/registry/reprovision.ts +++ b/backend/routes/registry/reprovision.ts @@ -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, @@ -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, diff --git a/backend/services/agentAutoJoinService.ts b/backend/services/agentAutoJoinService.ts index 293caf9dca..b7f7b7475d 100644 --- a/backend/services/agentAutoJoinService.ts +++ b/backend/services/agentAutoJoinService.ts @@ -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)); @@ -76,16 +77,24 @@ class AgentAutoJoinService { try { // eslint-disable-next-line no-await-in-loop - await assertAgentRuntimeMayJoinPod((pod as Record)._id, runtimeTypeFromConfig(sourceInstall.config)); + await assertAgentRuntimeMayJoinPod( + (pod as Record)._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, - 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, + 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)._id); try { diff --git a/backend/services/agentIdentityService.ts b/backend/services/agentIdentityService.ts index 6bb8f43f42..b2d05953c7 100644 --- a/backend/services/agentIdentityService.ts +++ b/backend/services/agentIdentityService.ts @@ -654,7 +654,8 @@ class AgentIdentityService { } static async ensureAgentInPod( - agentUser: InstanceType, podId: unknown, intendedRuntimeType?: string, + agentUser: InstanceType, podId: unknown, + intendedRuntimeType?: string, intendedHostedEngine?: string, ): Promise | null> { if (!agentUser || !podId) return null; const pod = await Pod.findById(podId); @@ -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, diff --git a/backend/services/hostedEngine.ts b/backend/services/hostedEngine.ts index 67a67a3fc6..428aeed4e9 100644 --- a/backend/services/hostedEngine.ts +++ b/backend/services/hostedEngine.ts @@ -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'; @@ -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 diff --git a/backend/services/outsiderGrantAgentJoinPolicy.ts b/backend/services/outsiderGrantAgentJoinPolicy.ts index f2a24271ee..c5f27528e2 100644 --- a/backend/services/outsiderGrantAgentJoinPolicy.ts +++ b/backend/services/outsiderGrantAgentJoinPolicy.ts @@ -1,5 +1,6 @@ /** Keep non-native agents out of pods that hold outsider-writable grants. */ import { AgentInstallation } from '../models/AgentRegistry'; +import { runsInNarrowedProcess } from './hostedEngine'; import { podHasOutsiderWritableGrant } from './nativeOutsiderGrantPolicy'; export class OutsiderGrantAgentJoinError extends Error { @@ -20,8 +21,10 @@ export const runtimeTypeFromConfig = (config: unknown): string => { ? String((runtime as { runtimeType?: unknown }).runtimeType || '').trim().toLowerCase() : ''; }; -export const assertAgentRuntimeMayJoinPod = async (podId: unknown, runtimeType: unknown): Promise => { - if (String(runtimeType || '').trim().toLowerCase() === 'native') return; +export const assertAgentRuntimeMayJoinPod = async ( + podId: unknown, runtimeType: unknown, hostedEngine?: unknown, +): Promise => { + if (runsInNarrowedProcess({ config: { runtime: { runtimeType }, hosted: { engine: hostedEngine } } })) return; if (await podHasOutsiderWritableGrant(String(podId))) throw new OutsiderGrantAgentJoinError(); }; @@ -37,5 +40,5 @@ export const assertInstalledAgentMayJoinPod = async ( instanceId: String(instanceId || 'default'), status: 'active', }).select('config').lean(); - if (runtimeTypeFromConfig(installation?.config) !== 'native') throw new OutsiderGrantAgentJoinError(); + if (!runsInNarrowedProcess(installation)) throw new OutsiderGrantAgentJoinError(); }; diff --git a/backend/services/outsiderGrantPodEligibility.ts b/backend/services/outsiderGrantPodEligibility.ts index d60946ba21..23b70a5e78 100644 --- a/backend/services/outsiderGrantPodEligibility.ts +++ b/backend/services/outsiderGrantPodEligibility.ts @@ -3,8 +3,7 @@ import { AgentInstallation } from '../models/AgentRegistry'; import Integration from '../models/Integration'; import Pod from '../models/Pod'; import User from '../models/User'; -import { HOSTED_ENGINE_NATIVE, hostedEngineOf } from './hostedEngine'; -import { isHostedInstallation } from './hostedRuntimeService'; +import { runsInNarrowedProcess } from './hostedEngine'; export type OutsiderGrantRefusal = 'pod_required' | 'pod_not_private' | 'pod_joinable' | 'pod_bridged' | 'non_native_agent' | 'write_mode_unconfined'; @@ -18,12 +17,6 @@ export const outsiderGrantRefusalMessage = (reason: OutsiderGrantRefusal): strin write_mode_unconfined: 'This grant permits unattended writes. Use write-with-confirm in a separate pod.', }[reason]); -const configObject = (value: unknown): Record => { - if (value instanceof Map) return Object.fromEntries(value); - return value && typeof value === 'object' && !Array.isArray(value) - ? value as Record : {}; -}; - /** Re-read at mint and on every broker list/call; no cached eligibility. */ export const outsiderGrantPodRefusal = async ( target: { kind?: string; id?: unknown }, @@ -63,16 +56,12 @@ export const outsiderGrantPodRefusal = async ( `${String(agent.botMetadata?.agentName || '').toLowerCase()}:${String(agent.botMetadata?.instanceId || 'default')}` ))); for (const installation of installations) { - const config = configObject(installation.config); - const runtime = configObject(config.runtime); const key = `${installation.agentName}:${String(installation.instanceId || 'default')}`; // A hosted agent on the native engine runs through the same narrowed // in-process runtime and can hold no runtime token (services/hostedEngine.ts, // routes/registry/tokens.ts). A hosted agent on the Durable Object polls // with a token from outside the backend, so it stays refused. - const inProcess = runtime.runtimeType === 'native' - || (isHostedInstallation(installation) && hostedEngineOf(installation) === HOSTED_ENGINE_NATIVE); - if (!inProcess || !agentKeys.has(key)) return 'non_native_agent'; + if (!runsInNarrowedProcess(installation) || !agentKeys.has(key)) return 'non_native_agent'; } for (const agent of agents) { if (agent.botType !== 'agent') return 'non_native_agent';