diff --git a/forge/routes/api/assistant.js b/forge/routes/api/assistant.js index 7c4a4c4614..8912001703 100644 --- a/forge/routes/api/assistant.js +++ b/forge/routes/api/assistant.js @@ -121,6 +121,41 @@ module.exports = async function (app) { } } }) + /** + * Endpoint for nr-assistant to check, live, whether the team has opted in to + * agent-initiated deploys. Deliberately not cached/pushed via settings.js - it + * must be checked at the moment a deploy is being considered so that turning the + * team setting off takes effect immediately, without an instance restart. + * @name /api/v1/assistant/deploy-policy + * @static + * @memberof forge.routes.api.assistant + */ + app.get('/deploy-policy', { + config: { + rateLimit: app.config.rate_limits + ? { + hook: 'preHandler', // apply the rate as a preHandler so that session is available + max: 60, // max requests per window + timeWindow: 60000, // 1 minute window + keyGenerator: (request) => { + return request.ownerId || request.ip + } + } + : false + }, + schema: { + hide: true // dont show in swagger + } + }, async (request, reply) => { + const isAiEnabled = !!(app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true)) + // agentAutoDeploy authorises unattended deploys, so it must be an explicit team-level + // opt-in - read the team's own override directly rather than through + // Team.getFeatureProperty, which falls back to TeamType.getFeatureProperty and would + // return true for every team on any TeamType bootstrapped with enableAllFeatures (the + // platform default), regardless of whether anyone actually opted in. + const autoDeploy = isAiEnabled && request.team?.properties?.features?.agentAutoDeploy === true + reply.send({ autoDeploy }) + }) /** * Endpoint for FIM (fill-in-the-middle) code completion requests * For now, this is simply a relay to an external assistant service diff --git a/forge/routes/api/team.js b/forge/routes/api/team.js index 6cda6abc2b..c13d46a90b 100644 --- a/forge/routes/api/team.js +++ b/forge/routes/api/team.js @@ -913,7 +913,7 @@ module.exports = async function (app) { } } else if (Object.hasOwn(request.body, 'features')) { // Team owners can update feature overrides (e.g. opt out of AI) - const allowedFeatures = ['ai'] + const allowedFeatures = ['ai', 'agentAutoDeploy'] const requestedFeatures = request.body.features || {} const currentProperties = typeof request.team.properties === 'string' ? JSON.parse(request.team.properties) : (request.team.properties || {}) currentProperties.features = currentProperties.features || {} diff --git a/frontend/src/composables/TeamProperties.js b/frontend/src/composables/TeamProperties.js index 09cf41fd79..e8d68f41db 100644 --- a/frontend/src/composables/TeamProperties.js +++ b/frontend/src/composables/TeamProperties.js @@ -21,10 +21,10 @@ function getProperty (properties, key) { } export function getTeamProperty (team, property, defaultValue) { - const teamValue = getProperty(team.properties, property) + const teamValue = getProperty(team?.properties, property) if (teamValue === undefined) { // No value found in team properties. Check the TeamType properties - return getProperty(team.type.properties, property) ?? defaultValue + return getProperty(team?.type?.properties, property) ?? defaultValue } return teamValue } diff --git a/frontend/src/pages/team/Settings/Danger.vue b/frontend/src/pages/team/Settings/Danger.vue index 92c977c9a0..ac25193370 100644 --- a/frontend/src/pages/team/Settings/Danger.vue +++ b/frontend/src/pages/team/Settings/Danger.vue @@ -56,6 +56,16 @@ + AI Flow Deploy +
+
+
Allow AI agents to deploy flow changes they make on this team's instances, without waiting for a person to click Deploy.
+
Enable AI Features above to use this.
+
+
+ +
+
@@ -68,6 +78,7 @@ import teamApi from '../../../api/team.js' import teamTypesApi from '../../../api/teamTypes.js' import FormHeading from '../../../components/FormHeading.vue' +import { getTeamProperty } from '../../../composables/TeamProperties.js' import alerts from '../../../services/alerts.js' import Dialog from '../../../services/dialog.js' @@ -92,7 +103,8 @@ export default { data () { return { teamTypes: [], - aiEnabledOverride: null + aiEnabledOverride: null, + agentAutoDeployOverride: null } }, computed: { @@ -107,11 +119,22 @@ export default { if (this.aiEnabledOverride !== null) { return this.aiEnabledOverride } - return this.team?.type?.properties?.features?.ai !== false + return getTeamProperty(this.team, 'features.ai', true) !== false }, set (value) { this.aiEnabledOverride = value } + }, + agentAutoDeploy: { + get () { + if (this.agentAutoDeployOverride !== null) { + return this.agentAutoDeployOverride + } + return !!getTeamProperty(this.team, 'features.agentAutoDeploy', false) + }, + set (value) { + this.agentAutoDeployOverride = value + } } }, async created () { @@ -175,6 +198,29 @@ export default { }, () => { this.aiEnabledOverride = null }) + }, + showConfirmAgentAutoDeployToggleDialog () { + const enabling = this.agentAutoDeploy + Dialog.show({ + header: enabling ? 'Enable AI Flow Deploy' : 'Disable AI Flow Deploy', + kind: enabling ? 'danger' : 'primary', + text: enabling + ? 'Are you sure you want to allow AI agents to deploy flows on this team\'s instances?' + : 'Are you sure you want to disable AI agents deploying flow changes automatically?', + confirmLabel: enabling ? 'Enable' : 'Disable' + }, () => { + teamApi.updateTeam(this.team.id, { features: { agentAutoDeploy: enabling } }).then(() => { + alerts.emit(`AI Flow Deploy ${enabling ? 'enabled' : 'disabled'}`, 'confirmation') + this.agentAutoDeployOverride = null + useContextStore().refreshTeam() + }).catch(err => { + alerts.emit('Problem updating AI Flow Deploy settings', 'warning') + this.agentAutoDeployOverride = null + console.warn(err) + }) + }, () => { + this.agentAutoDeployOverride = null + }) } } } diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index 09e30c2e4f..3dd97d6b2b 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -49,6 +49,19 @@ export const useContextStore = defineStore('context', { } return getTeamProperty(this.team, 'trial.runtimesLimit') ?? null }, + // Mirrors Team.getFeatureProperty on the backend (team override falling back to the + // TeamType/plan default), gated on both the platform `ai` feature and the team's own `ai` + // opt-out - matching what the live check `/api/v1/assistant/deploy-policy` actually gates + // on (`isAiEnabled = platform 'ai' && team.getFeatureProperty('ai', true)`). + agentAutoDeployEnabled () { + if (!this.team) { + return false + } + const platformAiEnabled = useAccountSettingsStore().featuresCheck?.isAiFeatureEnabledForPlatform + const teamAiEnabled = getTeamProperty(this.team, 'features.ai', true) + const agentAutoDeploy = getTeamProperty(this.team, 'features.agentAutoDeploy', false) + return !!(platformAiEnabled && teamAiEnabled && agentAutoDeploy) + }, editorEntityType (state) { const name = state.route?.name if (name?.startsWith('instance-editor')) return 'instance' @@ -98,6 +111,7 @@ export const useContextStore = defineStore('context', { teamId: this.team?.id || null, teamSlug: this.team?.slug || null, telemetryEnabled: useAccountSettingsStore().featuresCheck?.isTelemetryEnabled ?? false, + agentAutoDeployEnabled: this.agentAutoDeployEnabled, instanceId: null, deviceId: null, applicationId: null, @@ -142,6 +156,7 @@ export const useContextStore = defineStore('context', { teamId: this.team?.id || null, teamSlug: this.team?.slug || null, telemetryEnabled: useAccountSettingsStore().featuresCheck?.isTelemetryEnabled ?? false, + agentAutoDeployEnabled: this.agentAutoDeployEnabled, instanceId: state.instance ? state.instance.id : null, deviceId: state.device ? state.device.id : null, applicationId: this.application ? this.application.id : null, diff --git a/test/unit/forge/routes/api/assistant_spec.js b/test/unit/forge/routes/api/assistant_spec.js index aeffe9b831..1bcdc2ff79 100644 --- a/test/unit/forge/routes/api/assistant_spec.js +++ b/test/unit/forge/routes/api/assistant_spec.js @@ -560,6 +560,91 @@ describe('Assistant API', async function () { }) }) + describe('deploy-policy endpoint', async function () { + // agentAutoDeploy authorises unattended deploys, so the route reads the team's own + // properties.features.agentAutoDeploy directly - it must never inherit "true" through + // Team.getFeatureProperty's TeamType fallback, which would make it default-on for + // every team on a TeamType bootstrapped with enableAllFeatures (the platform default - + // see forge/db/controllers/TeamType.js). These tests don't need to touch the TeamType + // at all as a result; the one below that does is there specifically to prove that. + let originalTeamProperties + beforeEach(async function () { + originalTeamProperties = TestObjects.ATeam.properties + }) + afterEach(async function () { + TestObjects.ATeam.properties = originalTeamProperties + await TestObjects.ATeam.save() + }) + it('reports autoDeploy false by default', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/assistant/deploy-policy', + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance } + }) + response.statusCode.should.equal(200) + response.json().should.have.property('autoDeploy', false) + }) + it('reports autoDeploy false when the TeamType has enableAllFeatures: true and the team has never opted in', async function () { + const defaultTeamType = await app.db.models.TeamType.findOne({ where: { name: 'starter' } }) + const originalTeamTypeProperties = JSON.parse(JSON.stringify(defaultTeamType.properties)) + try { + const props = defaultTeamType.properties + props.enableAllFeatures = true + defaultTeamType.properties = props + await defaultTeamType.save() + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/assistant/deploy-policy', + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance } + }) + response.statusCode.should.equal(200) + response.json().should.have.property('autoDeploy', false) + } finally { + defaultTeamType.properties = originalTeamTypeProperties + await defaultTeamType.save() + } + }) + it('reports autoDeploy true when the team has enabled it and ai is enabled', async function () { + TestObjects.ATeam.properties = { features: { agentAutoDeploy: true } } + await TestObjects.ATeam.save() + const response = await app.inject({ + method: 'GET', + url: '/api/v1/assistant/deploy-policy', + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance } + }) + response.statusCode.should.equal(200) + response.json().should.have.property('autoDeploy', true) + }) + it('reports autoDeploy false when agentAutoDeploy is enabled but the team has opted out of ai', async function () { + TestObjects.ATeam.properties = { features: { agentAutoDeploy: true, ai: false } } + await TestObjects.ATeam.save() + const response = await app.inject({ + method: 'GET', + url: '/api/v1/assistant/deploy-policy', + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance } + }) + response.statusCode.should.equal(200) + response.json().should.have.property('autoDeploy', false) + }) + it('reports autoDeploy false when the platform ai feature is disabled', async function () { + TestObjects.ATeam.properties = { features: { agentAutoDeploy: true } } + await TestObjects.ATeam.save() + app.config.features.register('ai', false, true) + try { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/assistant/deploy-policy', + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance } + }) + response.statusCode.should.equal(200) + response.json().should.have.property('autoDeploy', false) + } finally { + app.config.features.register('ai', true, true) + } + }) + }) + describe('assets endpoint', async function () { const assetUrl1 = '/api/v1/assistant/assets/model.json' const assetUrl2 = '/api/v1/assistant/assets/model.bin' diff --git a/test/unit/forge/routes/api/team_spec.js b/test/unit/forge/routes/api/team_spec.js index 1b5fdfb847..024f0c67d9 100644 --- a/test/unit/forge/routes/api/team_spec.js +++ b/test/unit/forge/routes/api/team_spec.js @@ -1630,6 +1630,62 @@ describe('Team API', function () { team.should.have.property('TeamTypeId', newTeamType.id) }) + describe('Feature overrides (features branch)', async function () { + // PUT /api/v1/teams/:teamId { features: {...} } + it('owner can toggle agentAutoDeploy', async function () { + const team = await app.db.models.Team.create({ name: 'update-team-feat-1', slug: 'team-feat-1', TeamTypeId: app.defaultTeamType.id }) + await team.addUser(TestObjects.bob, { through: { role: Roles.Owner } }) + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/teams/${team.hashid}`, + payload: { + features: { agentAutoDeploy: true } + }, + cookies: { sid: TestObjects.tokens.bob } + }) + response.statusCode.should.equal(200) + + await team.reload() + team.properties.features.should.have.property('agentAutoDeploy', true) + }) + it('member cannot toggle agentAutoDeploy', async function () { + const team = await app.db.models.Team.create({ name: 'update-team-feat-2', slug: 'team-feat-2', TeamTypeId: app.defaultTeamType.id }) + await team.addUser(TestObjects.bob, { through: { role: Roles.Member } }) + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/teams/${team.hashid}`, + payload: { + features: { agentAutoDeploy: true } + }, + cookies: { sid: TestObjects.tokens.bob } + }) + response.statusCode.should.equal(403) + + await team.reload() + should.not.exist(team.properties?.features?.agentAutoDeploy) + }) + it('ignores feature keys outside the allowlist', async function () { + const team = await app.db.models.Team.create({ name: 'update-team-feat-3', slug: 'team-feat-3', TeamTypeId: app.defaultTeamType.id }) + await team.addUser(TestObjects.bob, { through: { role: Roles.Owner } }) + + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/teams/${team.hashid}`, + payload: { + features: { agentAutoDeploy: true, notAllowed: true } + }, + cookies: { sid: TestObjects.tokens.bob } + }) + response.statusCode.should.equal(200) + + await team.reload() + team.properties.features.should.have.property('agentAutoDeploy', true) + should.not.exist(team.properties.features.notAllowed) + }) + }) + describe('Suspending team', async function () { it('non-owner cannot suspend team', async function () { const teamName = generateName('suspend-team') diff --git a/test/unit/frontend/composables/TeamProperties.spec.js b/test/unit/frontend/composables/TeamProperties.spec.js new file mode 100644 index 0000000000..499dca2251 --- /dev/null +++ b/test/unit/frontend/composables/TeamProperties.spec.js @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest' + +import { getTeamProperty } from '../../../../frontend/src/composables/TeamProperties.js' + +describe('getTeamProperty', () => { + test('returns the team-level override when present', () => { + const team = { properties: { features: { agentAutoDeploy: true } }, type: { properties: { features: { agentAutoDeploy: false } } } } + expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(true) + }) + + test('falls back to the TeamType property when the team has no override', () => { + const team = { properties: {}, type: { properties: { features: { agentAutoDeploy: true } } } } + expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(true) + }) + + test('falls back to the default value when neither has the property', () => { + const team = { properties: {}, type: { properties: { features: {} } } } + expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(false) + }) + + test('does not throw when the team has no type', () => { + const team = { id: 'team-1', slug: 'my-team' } + expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(false) + }) + + test('does not throw when team is null or undefined', () => { + expect(getTeamProperty(null, 'features.agentAutoDeploy', false)).toBe(false) + expect(getTeamProperty(undefined, 'features.agentAutoDeploy', false)).toBe(false) + }) + + test('resolves a nested dotted path', () => { + const team = { properties: {}, type: { properties: { trial: { runtimesLimit: 3 } } } } + expect(getTeamProperty(team, 'trial.runtimesLimit')).toBe(3) + }) +}) diff --git a/test/unit/frontend/stores/context.spec.js b/test/unit/frontend/stores/context.spec.js index 8c54e38ab7..1bc5c4350d 100644 --- a/test/unit/frontend/stores/context.spec.js +++ b/test/unit/frontend/stores/context.spec.js @@ -277,6 +277,53 @@ describe('context store', () => { }) }) + describe('agentAutoDeployEnabled', () => { + it('returns false when there is no team', () => { + const store = useContextStore() + expect(store.agentAutoDeployEnabled).toBe(false) + }) + + it('returns false when the platform ai feature is disabled', () => { + const store = useContextStore() + const settingsStore = useAccountSettingsStore() + settingsStore.features = { ai: false } + store.setTeam({ properties: { features: { agentAutoDeploy: true } }, type: { properties: { features: {} } } }) + expect(store.agentAutoDeployEnabled).toBe(false) + }) + + it('returns false when the team has opted out of ai', () => { + const store = useContextStore() + const settingsStore = useAccountSettingsStore() + settingsStore.features = { ai: true } + store.setTeam({ properties: { features: { agentAutoDeploy: true, ai: false } }, type: { properties: { features: {} } } }) + expect(store.agentAutoDeployEnabled).toBe(false) + }) + + it('returns false when the team has not enabled agentAutoDeploy', () => { + const store = useContextStore() + const settingsStore = useAccountSettingsStore() + settingsStore.features = { ai: true } + store.setTeam({ properties: {}, type: { properties: { features: {} } } }) + expect(store.agentAutoDeployEnabled).toBe(false) + }) + + it('returns true when the team override enables it and ai is enabled', () => { + const store = useContextStore() + const settingsStore = useAccountSettingsStore() + settingsStore.features = { ai: true } + store.setTeam({ properties: { features: { agentAutoDeploy: true } }, type: { properties: { features: {} } } }) + expect(store.agentAutoDeployEnabled).toBe(true) + }) + + it('falls back to the TeamType default when the team has no override', () => { + const store = useContextStore() + const settingsStore = useAccountSettingsStore() + settingsStore.features = { ai: true } + store.setTeam({ properties: {}, type: { properties: { features: { agentAutoDeploy: true } } } }) + expect(store.agentAutoDeployEnabled).toBe(true) + }) + }) + describe('isTrialAccount', () => { it('returns false when team has no billing', () => { const store = useContextStore() @@ -461,6 +508,23 @@ describe('context store', () => { expect(expert.teamSlug).toBe('my-team') }) + // Both branches build the object separately, so a field added to one + // and not the other goes missing depending on load timing + it('carries agentAutoDeployEnabled on both the early-return and main paths', () => { + const store = useContextStore() + const settingsStore = useAccountSettingsStore() + settingsStore.features = { ai: true } + store.setTeam({ properties: { features: { agentAutoDeploy: true } }, type: { properties: { features: {} } } }) + + expect(store.route).toBe(null) + expect(store.expert.agentAutoDeployEnabled).toBe(true) + + store.setTeamMembership({ role: 30 }) + store.updateRoute({ name: 'team', fullPath: '/team/a', params: {} }) + expect(store.route).not.toBe(null) + expect(store.expert.agentAutoDeployEnabled).toBe(true) + }) + it('sets telemetryEnabled from the platform setting', () => { const store = useContextStore() const settingsStore = useAccountSettingsStore()