diff --git a/forge/ee/lib/mcp/schemas.js b/forge/ee/lib/mcp/schemas.js index f6fcb12903..1930f2e005 100644 --- a/forge/ee/lib/mcp/schemas.js +++ b/forge/ee/lib/mcp/schemas.js @@ -7,6 +7,16 @@ const hostedInstanceId = z.string().uuid().describe('The id (UUID) of the hosted const remoteInstanceId = z.string().describe('The hashid of the remote instance') const snapshotId = z.string().describe('The hashid of the snapshot') +// Shared by the snapshot export and import tools: both routes take the same +// component selection, and the controller applies the same defaults to each. +// Direction-specific cautions (an export exposing hidden values, for instance) +// belong in the owning tool's description, not here. +const snapshotComponents = z.object({ + flows: z.boolean().optional().describe('Include the flows (default true). Excluding flows also excludes credentials'), + credentials: z.boolean().optional().describe('Include the encrypted flow credentials (default true)'), + envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables: "all" keeps keys and values (default), "keys" keeps only the names, false removes them entirely. Note "keys" also drops the hidden flag, so a secret variable comes back as an ordinary empty one') +}).optional().describe('Optional selection of which snapshot components to include') + // Query fragments composed per tool by spreading only the ones the backing // route's finder actually honors. Not the same as the route's declared query // schema: most list routes reuse a generic PaginationParams that advertises @@ -85,6 +95,7 @@ module.exports = { hostedInstanceId, remoteInstanceId, snapshotId, + snapshotComponents, cursorParam, limitParam, basePagination, diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 280a26a1ee..9e64dca940 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -1,8 +1,24 @@ const { z } = require('zod') -const { basePaginationKeys, limitParam, appendQuery, hostedInstanceId, snapshotId } = require('../schemas') +const { basePaginationKeys, limitParam, appendQuery, hostedInstanceId, snapshotId, snapshotComponents, toolError } = require('../schemas') const { blankHiddenEnvValues } = require('../utils') +// Width of ProjectSnapshot.name, a DataTypes.STRING column. +const SNAPSHOT_NAME_MAX_LENGTH = 255 + +// An env var in a snapshot is either a plain value or an object carrying the +// hidden flag, with "$" holding the encrypted value once it has been exported. +// Spelling the shape out keeps null and other scalars from reaching the import +// route, which reads every value's properties unguarded. +const envVarValue = z.union([ + z.string(), + z.object({ + value: z.string().optional().describe('The value, for an env var that is not hidden or has been decrypted'), + hidden: z.boolean().optional().describe('Whether this is a secret env var'), + $: z.string().optional().describe('The encrypted value, as produced by platform_export_snapshot') + }).loose() +]) + module.exports = [ { name: 'platform_list_instance_snapshots', @@ -99,6 +115,206 @@ module.exports = [ return { statusCode: response.statusCode, json: () => body } } }, + { + name: 'platform_update_snapshot', + title: 'Update Snapshot', + description: `FlowFuse platform automation tool: + Updates a snapshot's name and/or description. Works for snapshots owned by a hosted instance or a remote instance (device); the owner is resolved automatically from the snapshot. + This is a partial update: only the fields you pass are changed, omitted fields keep their stored value. Pass at least one of name or description.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: { + snapshotId, + name: z.string().min(1).max(SNAPSHOT_NAME_MAX_LENGTH).optional().describe(`New name for the snapshot. Must be non-empty and at most ${SNAPSHOT_NAME_MAX_LENGTH} characters when provided; omit to keep the current name`), + description: z.string().optional().describe('New description for the snapshot. Pass an empty string to clear it; omit to keep the current description') + }, + handler: async (args, { inject }) => { + // Tool input is not validated platform-side, so the schema's own + // constraints are advisory. The controller throws a sequelize + // ValidationError for a blank name and nothing maps that to a status, + // so an empty name would surface as a 500 rather than a usable error. + if (args.name !== undefined && args.name.trim() === '') { + return toolError(400, 'invalid_request', 'name must be a non-empty string; omit it to keep the current name') + } + // The name column is 255 wide, and an over-long value surfaces as a 500 + // carrying the raw database error. Note this only bites on postgres: + // sqlite ignores the declared width, so it passes there. + if (args.name !== undefined && args.name.length > SNAPSHOT_NAME_MAX_LENGTH) { + return toolError(400, 'invalid_request', `name must be ${SNAPSHOT_NAME_MAX_LENGTH} characters or fewer`) + } + const payload = {} + if (args.name !== undefined) { + payload.name = args.name + } + if (args.description !== undefined) { + payload.description = args.description + } + // The route replies 200 with the unchanged snapshot when there is + // nothing to update, which reads as a successful edit that never happened. + if (Object.keys(payload).length === 0) { + return toolError(400, 'invalid_request', 'Pass at least one of name or description to update') + } + const response = await inject({ method: 'PUT', url: `/api/v1/snapshots/${args.snapshotId}`, payload }) + return response + } + }, + { + name: 'platform_export_snapshot', + title: 'Export Snapshot', + description: `FlowFuse platform automation tool: + Exports the full content of a snapshot (flows, credentials, settings, and environment variables) so it can be imported elsewhere with platform_import_snapshot. Works for snapshots owned by a hosted instance or a remote instance (device); the owner is resolved automatically from the snapshot. + credentialSecret is always required, even when credentials are excluded via components - the route rejects the request with a 400 without it. The exported credentials are re-encrypted with this secret, and the SAME secret must be supplied when importing the result, so remember it. + The export contains sensitive data: by default it includes the encrypted flow credentials and the values of ALL environment variables, including hidden (secret) ones. Use components to narrow what is included, for example envVars: "keys" to strip env values. + Environment variables whose names start with FF_ are reserved by the platform and are never included in an export. + Unless credentials are excluded (components credentials: false or flows: false), the export carries an encrypted credentials block even when the snapshot has no flows and no credentials at all, so platform_import_snapshot will need this same secret to take the result back in. + This payload can be large, and is always a superset of platform_get_snapshot_full, so only call it when the exported content is actually needed. + Unlike platform_get_snapshot_full, this is treated as a write operation because it extracts credentials and secret values.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: { + snapshotId, + credentialSecret: z.string().min(1).describe('Secret used to re-encrypt the exported credentials. Required on every export; the same secret is needed to import the result'), + components: snapshotComponents + }, + handler: async (args, { inject }) => { + // The route treats a blank secret as an absent one and answers 400. + // Schema constraints are advisory platform-side, so check it here too. + if (!args.credentialSecret) { + return toolError(400, 'invalid_request', 'credentialSecret is required and must not be empty') + } + const payload = { credentialSecret: args.credentialSecret } + if (args.components !== undefined) { + payload.components = args.components + } + const response = await inject({ method: 'POST', url: `/api/v1/snapshots/${args.snapshotId}/export`, payload }) + return response + } + }, + { + name: 'platform_import_snapshot', + title: 'Import Snapshot', + description: `FlowFuse platform automation tool: + Imports a previously exported snapshot into a hosted instance or a remote instance (device), creating a new snapshot owned by that target. The response is the new snapshot's metadata; importing does not deploy it. + Pass the result of platform_export_snapshot straight through as snapshot; the extra fields an export carries (id, createdAt, updatedAt, ownerType, user, exportedBy) are accepted and ignored. + ownerId must match ownerType: pass the hosted instance UUID with ownerType "instance", or the device hashid with ownerType "device". + credentialSecret must be the same secret used at export. Every snapshot produced by platform_export_snapshot carries an encrypted credentials block, even one with no flows and no credentials, so a secret is in practice always required for an export. It can only be left out for a hand-built snapshot with no encrypted material, or when credentials are excluded with components flows: false or credentials: false. Hidden environment variable values (env entries carrying a "$" property) need it too, unless env vars are dropped with envVars: false or reduced to their names with envVars: "keys". + A wrong secret is only detected through flow credentials (400 "Invalid credential secret"); when no flow credentials are imported, a wrong secret cannot be detected and hidden env values import as corrupted data, so double-check the secret first. + Flow credentials must be encrypted: pass the credentials object from an export, carrying a single "$" property. Unencrypted credentials are rejected, because the platform stores them exactly as given and they would sit in the clear. + Environment variables whose names start with FF_ are reserved by the platform and are dropped on import, so they will not appear in the created snapshot. + Use components to import selectively, for example envVars: "keys" to import env var names without their values.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: { + ownerId: z.string().describe('Target owner id: hosted instance (project) UUID when ownerType is "instance", or device hashid when ownerType is "device"'), + ownerType: z.enum(['instance', 'device']).describe('Type of resource that will own the imported snapshot'), + snapshot: z.object({ + name: z.string().describe('Name for the imported snapshot'), + description: z.string().optional().describe('Description for the imported snapshot (defaults to empty)'), + flows: z.object({ + flows: z.array(z.any()).describe('Node-RED flows array'), + credentials: z.record(z.string(), z.any()).optional().describe('Encrypted credentials object, as produced by platform_export_snapshot') + }).describe('Flows payload'), + settings: z.object({ + settings: z.record(z.string(), z.any()).optional().describe('Runtime settings'), + env: z.record(z.string(), envVarValue).optional().describe('Environment variables, either "NAME": "value" or "NAME": { "value": "...", "hidden": true } for secrets (defaults to none)'), + modules: z.record(z.string(), z.any()).optional().describe('Installed module versions') + }).describe('Settings payload') + // Loose so an export can be handed over untouched: it carries id, + // createdAt, updatedAt, ownerType, user and exportedBy on top of the + // four fields the route reads. The handler forwards only those four. + }).loose().describe('The snapshot content to import, typically the result of platform_export_snapshot'), + credentialSecret: z.string().optional().describe('Secret to decrypt the snapshot: the secret used when the snapshot was exported. Required when the snapshot contains encrypted flow credentials or hidden env values'), + components: snapshotComponents + }, + handler: async (args, { inject }) => { + // Forward only the four fields the route reads, so the export metadata + // the schema now tolerates is not echoed back into the request body. + const { name, description, flows, settings } = args.snapshot || {} + const snapshot = { name, flows: { ...flows }, settings: { ...settings } } + if (description !== undefined) { + snapshot.description = description + } + + // Excluding the flows drops the credentials along with them, but the + // route guards on the payload as given, so it asks for a secret it will + // never use. Strip them here the way excluded env vars are stripped below. + const credentialsExcluded = args.components?.flows === false || args.components?.credentials === false + if (credentialsExcluded) { + snapshot.flows.credentials = {} + } + + // The import route errors when settings.env is missing entirely, so + // normalise an omitted env to the empty set the route expects. Excluded + // env vars are stripped up front too: the route empties them anyway, but + // only after decrypting hidden values, which fails without the secret. + if (!snapshot.settings.env || args.components?.envVars === false) { + snapshot.settings.env = {} + } else if (args.components?.envVars === 'keys') { + // Same story for a keys-only import: the route keeps the names and + // discards every value, but decrypts the hidden ones first. Reducing + // to the names here produces the identical result without a secret. + snapshot.settings.env = Object.keys(snapshot.settings.env).reduce((acc, key) => { + acc[key] = '' + return acc + }, {}) + } + + // The route reads every env value's properties without checking it is + // an object, so a null slips through to a 500. The schema rules those + // out at the gateway; this covers callers the schema does not reach. + const invalidEnv = Object.keys(snapshot.settings.env).find(key => { + const value = snapshot.settings.env[key] + return value === null || (typeof value !== 'string' && typeof value !== 'object') + }) + if (invalidEnv) { + return toolError(400, 'invalid_request', `settings.env.${invalidEnv} must be a string or an object; the platform errors on any other value`) + } + + // Credentials are only re-encrypted for the target when they arrive + // encrypted; anything else is written to the snapshot verbatim and ends + // up stored in the clear, so reject it rather than leak it into the database. + const credentials = snapshot.flows.credentials + if (credentials && !credentials.$ && Object.keys(credentials).length > 0) { + return toolError(400, 'invalid_request', 'flows.credentials must be the encrypted object produced by platform_export_snapshot, carrying a single "$" property; unencrypted credentials would be stored in the clear') + } + + // The route only guards flow credentials before decrypting; a missing + // secret with encrypted hidden env values surfaces as a 500, so reject + // both encrypted cases here with a clear error instead. + const hasEncryptedEnv = Object.values(snapshot.settings.env).some(env => env && typeof env === 'object' && env.hidden && env.$) + const hasEncryptedCredentials = !credentialsExcluded && !!credentials?.$ + if ((hasEncryptedEnv || hasEncryptedCredentials) && !args.credentialSecret) { + return toolError(400, 'invalid_request', 'credentialSecret is required: the snapshot contains encrypted flow credentials or hidden environment variable values') + } + const payload = { ownerId: args.ownerId, ownerType: args.ownerType, snapshot } + if (args.credentialSecret !== undefined) { + payload.credentialSecret = args.credentialSecret + } + if (args.components !== undefined) { + payload.components = args.components + } + const response = await inject({ method: 'POST', url: '/api/v1/snapshots/import', payload }) + return response + } + }, + { + name: 'platform_set_instance_device_target', + title: 'Set Hosted Instance Device Target Snapshot', + description: `FlowFuse platform automation tool: + Sets the target snapshot for the remote instances (devices) assigned to a hosted instance. + CAUTION: this takes effect immediately - every device assigned to the instance is told to deploy the target snapshot as soon as it is set. Confirm with the user before calling this, and call platform_list_remote_instances with this hostedInstanceId first so you can tell them which devices it will hit. + The snapshot must belong to the given hosted instance, otherwise the route rejects it with "invalid_snapshot". + There is no way to clear a target snapshot through the API, not with this tool and not on the underlying route, which ignores a null target and answers 200 without changing anything. Once set, a target can only be replaced by another one, or removed by deleting the snapshot it points at, which clears it from the instance and from every assigned device. + Use platform_get_hosted_instance_device_target_snapshot to see the current target, and platform_list_instance_snapshots to find a snapshot id.`, + // destructiveHint: this overwrites what every assigned device is running, + // rather than adding to it, so it belongs behind destructive tool access. + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + inputSchema: { + hostedInstanceId, + snapshotId: snapshotId.describe('The hashid of the snapshot to set as the device target. Must be a snapshot of this hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'POST', url: `/api/v1/projects/${args.hostedInstanceId}/devices/settings`, payload: { targetSnapshot: args.snapshotId } }) + return response + } + }, { name: 'platform_get_hosted_instance_device_target_snapshot', title: 'Get Hosted Instance Device Target Snapshot', diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index 0149a33ebc..2a4cc901a6 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -181,4 +181,297 @@ describe('MCP Snapshots Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_update_snapshot', function () { + const tool = getTool('platform_update_snapshot') + + it('puts name and description onto the snapshot route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'snapshot1' }) } + inject.withArgs({ method: 'PUT', url: '/api/v1/snapshots/snapshot1', payload: { name: 'v2', description: 'second cut' } }).resolves(routeResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', name: 'v2', description: 'second cut' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('only sends the fields that were provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) + + await tool.handler({ snapshotId: 'snapshot1', name: 'v2' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ name: 'v2' }) + }) + + it('keeps an empty-string description so it can clear the stored value', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) + + await tool.handler({ snapshotId: 'snapshot1', description: '' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ description: '' }) + }) + + it('rejects a blank name without calling the route, which would 500 on it', async function () { + const response = await tool.handler({ snapshotId: 'snapshot1', name: ' ' }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects a name longer than the column, which the route 500s on', async function () { + const tooLong = 'x'.repeat(256) + tool.inputSchema.name.safeParse(tooLong).success.should.be.false() + tool.inputSchema.name.safeParse('x'.repeat(255)).success.should.be.true() + + const response = await tool.handler({ snapshotId: 'snapshot1', name: tooLong }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects an update with nothing to change, which the route answers 200 to', async function () { + const response = await tool.handler({ snapshotId: 'snapshot1' }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', name: 'v2' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_export_snapshot', function () { + const tool = getTool('platform_export_snapshot') + + it('posts the credential secret to the export route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'snapshot1', flows: {} }) } + inject.withArgs({ method: 'POST', url: '/api/v1/snapshots/snapshot1/export', payload: { credentialSecret: 's3cret' } }).resolves(routeResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', credentialSecret: 's3cret' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('rejects a blank credentialSecret without calling the route, which treats it as absent', async function () { + tool.inputSchema.credentialSecret.safeParse('').success.should.be.false() + + const response = await tool.handler({ snapshotId: 'snapshot1', credentialSecret: '' }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('forwards the components selection when provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) + + await tool.handler({ snapshotId: 'snapshot1', credentialSecret: 's3cret', components: { flows: true, credentials: false, envVars: 'keys' } }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ credentialSecret: 's3cret', components: { flows: true, credentials: false, envVars: 'keys' } }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'bad_request' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', credentialSecret: 's3cret' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_import_snapshot', function () { + const tool = getTool('platform_import_snapshot') + + const snapshot = { + name: 'imported', + flows: { flows: [] }, + settings: { env: { FOO: 'bar' } } + } + + it('posts the snapshot payload to the import route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'snapshot2' }) } + inject.withArgs({ + method: 'POST', + url: '/api/v1/snapshots/import', + payload: { ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'all' } } + }).resolves(routeResponse) + + const response = await tool.handler({ ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'all' } }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('omits credentialSecret and components when not provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ ownerId: 'device1', ownerType: 'device', snapshot }) + }) + + it('fills in settings.env when the snapshot omits it, since the route errors without it', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: { name: 'imported', flows: { flows: [] }, settings: {} } }, { inject }) + + inject.firstCall.args[0].payload.snapshot.settings.should.eql({ env: {} }) + }) + + it('strips env vars up front when components excludes them, so no secret is needed', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedEnv, components: { envVars: false } }, { inject }) + + inject.calledOnce.should.be.true() + inject.firstCall.args[0].payload.snapshot.settings.env.should.eql({}) + }) + + it('reduces env vars to their names when only keys are wanted, so no secret is needed', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' }, PLAIN: { value: 'keep' } } } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedEnv, components: { envVars: 'keys' } }, { inject }) + + inject.calledOnce.should.be.true() + inject.firstCall.args[0].payload.snapshot.settings.env.should.eql({ SECRET: '', PLAIN: '' }) + }) + + it('rejects a missing credentialSecret when the snapshot has encrypted hidden env values, since the route 500s', async function () { + const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedEnv }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects a missing credentialSecret when the snapshot has encrypted flow credentials that are not excluded', async function () { + const encryptedCreds = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: {} } } + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedCreds }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('lets an encrypted snapshot through when the credentialSecret is provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encrypted = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encrypted, credentialSecret: 's3cret' }, { inject }) + + inject.calledOnce.should.be.true() + }) + + it('lets encrypted credentials through without a secret when credentials are excluded', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedCreds = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: {} } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedCreds, components: { credentials: false } }, { inject }) + + inject.calledOnce.should.be.true() + }) + + it('accepts an export verbatim and forwards only the fields the route reads', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const exported = { + id: 'snapshot1', + name: 'imported', + description: 'from an export', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ownerType: 'instance', + user: { id: 'user1' }, + exportedBy: { id: 'user1' }, + flows: { flows: [], credentials: { $: 'abc123' } }, + settings: { env: {} } + } + + tool.inputSchema.snapshot.safeParse(exported).success.should.be.true() + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: exported, credentialSecret: 's3cret' }, { inject }) + + inject.firstCall.args[0].payload.snapshot.should.eql({ + name: 'imported', + description: 'from an export', + flows: { flows: [], credentials: { $: 'abc123' } }, + settings: { env: {} } + }) + }) + + it('lets encrypted credentials through without a secret when the flows are excluded', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedCreds = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: {} } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedCreds, components: { flows: false } }, { inject }) + + inject.calledOnce.should.be.true() + inject.firstCall.args[0].payload.snapshot.flows.credentials.should.eql({}) + }) + + it('rejects unencrypted flow credentials, which the route would store in the clear', async function () { + const plainCreds = { name: 'imported', flows: { flows: [], credentials: { node1: { password: 'hunter2' } } }, settings: { env: {} } } + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: plainCreds }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects an env value that is neither a string nor an object, since the route 500s on it', async function () { + const badEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { BROKEN: null } } } + + tool.inputSchema.snapshot.safeParse(badEnv).success.should.be.false() + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: badEnv }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'bad_request' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_set_instance_device_target', function () { + const tool = getTool('platform_set_instance_device_target') + + it('posts the target snapshot to the instance device settings route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) } + inject.withArgs({ method: 'POST', url: `/api/v1/projects/${hostedInstanceId}/devices/settings`, payload: { targetSnapshot: 'snapshot1' } }).resolves(routeResponse) + + const response = await tool.handler({ hostedInstanceId, snapshotId: 'snapshot1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_snapshot' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ hostedInstanceId, snapshotId: 'other' }, { inject }) + response.should.equal(errorResponse) + }) + }) })