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()