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
22 changes: 14 additions & 8 deletions forge/comms/platformAutomation.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,20 @@ class PlatformAutomationHandler {
const user = await this.app.db.models.User.byId(userId)
if (user) {
const { token } = await this.app.expert.mcp.getOrCreatePlatformToken(user)
const inject = (opts) => this.app.inject({
...opts,
headers: {
...opts.headers,
authorization: `Bearer ${token}`,
'x-ff-automation-source': 'expert'
}
})
const inject = (opts) => {
const nonce = this.app.nonceStore.createSourceNonce({
source: 'mcp:expert',
toolName
})
return this.app.inject({
...opts,
headers: {
...opts.headers,
authorization: `Bearer ${token}`,
'x-ff-source-nonce': nonce
}
})
}

const { formatResponse } = require('../ee/lib/mcp/toolLoader')
const response = await tool.handler(args, { inject })
Expand Down
36 changes: 33 additions & 3 deletions frontend/src/components/audit-log/AuditEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
</div>
</div>
<!-- User/Trigger -->
<div v-if="entry.trigger.name !== 'unknown'" class="ff-audit-entry-trigger lg:w-36">
{{ entry.trigger.name }}
<div v-if="entry.trigger.name !== 'unknown'" class="ff-audit-entry-trigger lg:w-36 flex items-center flex-nowrap">
<span class="flex-1 text-right">{{ entry.trigger.name }}</span>
<span v-if="sourceIcon" v-ff-tooltip:left="sourceLabel" class="flex-0 cursor-help"><component :is="sourceIcon" class="ff-icon-sm ml-1" /></span>
</div>
<div v-else class="ff-audit-entry-trigger lg:w-36">
&nbsp;
Expand All @@ -40,7 +41,7 @@

<script>

import { CpuChipIcon, RectangleGroupIcon, UserGroupIcon } from '@heroicons/vue/24/outline'
import { CommandLineIcon, CpuChipIcon, RectangleGroupIcon, SparklesIcon, UserGroupIcon } from '@heroicons/vue/24/outline'

import ProjectsIcon from '../../components/icons/Projects.js'

Expand All @@ -67,10 +68,39 @@ export default {
AuditEntryIcon,
AuditEntryVerbose,
ProjectsIcon,
CommandLineIcon,
CpuChipIcon,
RectangleGroupIcon,
SparklesIcon,
UserGroupIcon
},
computed: {
sourceIcon () {
if (!this.entry.source) {
return null
}
if (this.entry.source.startsWith('mcp')) {
return SparklesIcon
}
if (this.entry.source === 'api') {
return CommandLineIcon
}
return null
},
sourceLabel () {
const toolName = this.entry.body?.sourceContext?.toolName
if (this.entry.source === 'mcp:expert') {
return toolName ? `via Expert (Tool name: ${toolName})` : 'via Expert'
}
if (this.entry.source === 'mcp') {
return toolName ? `via MCP (Tool name: ${toolName})` : 'via MCP'
}
if (this.entry.source === 'api') {
return 'via API'
}
return ''
}
},
methods: {
getApplication (association) {
return association.ownerType === 'application' ? association : null
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/stylesheets/components/audit-log.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@
}

.ff-audit-entry-trigger, .ff-audit-entry-time {
grid-area: time;
display: block;
color: var(--ff-color-text-subtle);
overflow-x: hidden;
white-space: nowrap;
Expand All @@ -79,4 +77,4 @@
grid-template-areas: 'time entry author';
grid-template-columns: min-content 1fr min-content;
}
}
}
160 changes: 160 additions & 0 deletions test/unit/forge/comms/platformAutomation_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
const should = require('should') // eslint-disable-line
const sinon = require('sinon')

const setup = require('../routes/setup')

const FF_UTIL = require('flowforge-test-utils')
const { PlatformAutomationHandler } = FF_UTIL.require('forge/comms/platformAutomation')

describe('PlatformAutomationHandler', function () {
const license = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w'

let app
let handler

function mockClient () {
const handlers = {}
return {
on: (event, callback) => { handlers[event] = callback },
emit: function () {
const evt = arguments[0]
const args = Array.prototype.slice.call(arguments, 1)
handlers[evt].apply(null, args)
}
}
}

function invokeToolCall ({ userId, toolName, args, meta }) {
return new Promise((resolve) => {
const onSuccess = (result) => resolve({ ok: true, result })
const onError = (message, code, err) => resolve({ ok: false, message, code, err })
handler.eventHandler(
{
userId,
command: 'mcp-call-tool',
data: { name: toolName, input: args || {} },
meta
},
onSuccess,
onError
)
})
}

before(async function () {
app = await setup({
license,
ai: { enabled: true },
expert: { enabled: true }
})
})

after(async function () {
await app.close()
})

beforeEach(function () {
const client = mockClient()
handler = PlatformAutomationHandler(app, client)
})

afterEach(function () {
sinon.restore()
})

describe('source context nonce injection', function () {
it('mints a nonce with source mcp:expert and the correct toolName', async function () {
const createSpy = sinon.spy(app.nonceStore, 'createSourceNonce')
const tool = handler.findTool('platform_list_teams')

const res = await invokeToolCall({
userId: app.adminUser.hashid,
toolName: 'platform_list_teams',
meta: { toolDefinition: { annotations: tool.annotations } }
})

res.ok.should.be.true()
createSpy.calledOnce.should.be.true()
const nonceArgs = createSpy.firstCall.args[0]
nonceArgs.should.have.property('source', 'mcp:expert')
nonceArgs.should.have.property('toolName', 'platform_list_teams')
})

it('passes the nonce as x-ff-source-nonce header in the injected request', async function () {
const injectSpy = sinon.spy(app, 'inject')
const tool = handler.findTool('platform_list_teams')

const res = await invokeToolCall({
userId: app.adminUser.hashid,
toolName: 'platform_list_teams',
meta: { toolDefinition: { annotations: tool.annotations } }
})

res.ok.should.be.true()
injectSpy.calledOnce.should.be.true()
const injectOpts = injectSpy.firstCall.args[0]
injectOpts.headers.should.have.property('x-ff-source-nonce')
injectOpts.headers['x-ff-source-nonce'].should.be.a.String()
injectOpts.headers['x-ff-source-nonce'].should.match(/^[a-f0-9]{32}$/)
})

it('mints a fresh nonce per inject call', async function () {
const createSpy = sinon.spy(app.nonceStore, 'createSourceNonce')
const tool = handler.findTool('platform_list_teams')

// Call twice
await invokeToolCall({
userId: app.adminUser.hashid,
toolName: 'platform_list_teams',
meta: { toolDefinition: { annotations: tool.annotations } }
})
await invokeToolCall({
userId: app.adminUser.hashid,
toolName: 'platform_list_teams',
meta: { toolDefinition: { annotations: tool.annotations } }
})

createSpy.callCount.should.be.greaterThanOrEqual(2)
})

it('includes authorization header with the platform token', async function () {
const injectSpy = sinon.spy(app, 'inject')
const tool = handler.findTool('platform_list_teams')

await invokeToolCall({
userId: app.adminUser.hashid,
toolName: 'platform_list_teams',
meta: { toolDefinition: { annotations: tool.annotations } }
})

injectSpy.calledOnce.should.be.true()
const injectOpts = injectSpy.firstCall.args[0]
injectOpts.headers.should.have.property('authorization')
injectOpts.headers.authorization.should.startWith('Bearer ')
})
})

describe('end-to-end audit trail', function () {
it('tool call produces audit entry with source mcp:expert and toolName', async function () {
const tool = handler.findTool('platform_create_application')

const res = await invokeToolCall({
userId: app.adminUser.hashid,
toolName: 'platform_create_application',
args: { name: 'audit-trail-test-app', teamId: app.team.hashid },
meta: { toolDefinition: { annotations: tool.annotations } }
})

res.ok.should.be.true()

const entry = await app.db.models.AuditLog.findOne({
where: { event: 'application.created' },
order: [['createdAt', 'DESC']]
})
should.exist(entry)
entry.source.should.equal('mcp:expert')
const body = JSON.parse(entry.body)
body.sourceContext.should.have.property('toolName', 'platform_create_application')
})
})
})
Loading