From 877fadcb6c06b213c352cbf2d45b5c49c43c5dc0 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 19:00:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20per-instance=20file=20bridge=20?= =?UTF-8?q?=E2=80=94=20list/read/write=20via=20the=20new=20agent=20file=20?= =?UTF-8?q?manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/instances/:id/files (list) + /file (read), PUT /file (write) — tenant-guarded, routed through requestScoped to the per-instance corrosion.{license}.{instance}.files.cmd using the new agent's {op,path} protocol (jailed to the instance root, symlink-safe). files.view / files.manage perms. Foundation for the per-game config editor and for fixing the legacy VueFinder File Manager (which still speaks the retired Go fm_* protocol on the wrong subject and is broken under per-license auth — separate reconciliation). Co-Authored-By: Claude Fable 5 --- .../modules/instances/instances.controller.ts | 35 ++++++++++++- .../modules/instances/instances.service.ts | 51 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/backend-nest/src/modules/instances/instances.controller.ts b/backend-nest/src/modules/instances/instances.controller.ts index bb63b6a..3d81f3a 100644 --- a/backend-nest/src/modules/instances/instances.controller.ts +++ b/backend-nest/src/modules/instances/instances.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, Param } from '@nestjs/common'; +import { Controller, Post, Get, Put, Body, Param, Query } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; @@ -31,4 +31,37 @@ export class InstancesController { ) { return this.instances.rcon(licenseId, id, body.command); } + + @Get(':id/files') + @RequirePermission('files.view') + @ApiOperation({ summary: 'List a directory in the instance (jailed to its root)' }) + async listFiles( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Query('path') path?: string, + ) { + return this.instances.listFiles(licenseId, id, path ?? ''); + } + + @Get(':id/file') + @RequirePermission('files.view') + @ApiOperation({ summary: 'Read a text file from the instance (jailed, 5 MiB cap)' }) + async readFile( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Query('path') path: string, + ) { + return this.instances.readFile(licenseId, id, path); + } + + @Put(':id/file') + @RequirePermission('files.manage') + @ApiOperation({ summary: 'Write a text file in the instance (jailed)' }) + async writeFile( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() body: { path: string; content: string }, + ) { + return this.instances.writeFile(licenseId, id, body.path, body.content ?? ''); + } } diff --git a/backend-nest/src/modules/instances/instances.service.ts b/backend-nest/src/modules/instances/instances.service.ts index 4ffb77b..508eda4 100644 --- a/backend-nest/src/modules/instances/instances.service.ts +++ b/backend-nest/src/modules/instances/instances.service.ts @@ -46,4 +46,55 @@ export class InstancesService { // RCON can take longer than a lifecycle ack — give it more headroom. return this.nats.requestScoped(licenseId, subject, { func: 'rcon', command }, 12_000); } + + // ------------------------------------------------------------------------- + // File access — jailed to the instance root by the agent's file manager. + // The agent protocol (corrosion-host-agent/src/filemanager.rs): + // { op: list|read|write|delete|rename|mkdir|mkfile|move|copy, path, ... } + // reply: { status: 'success'|'error', data?, message? } + // ------------------------------------------------------------------------- + + private filesSubject(inst: GameInstance, licenseId: string): string { + return `corrosion.${licenseId}.${inst.agent_instance_id}.files.cmd`; + } + + private async fileOp( + licenseId: string, + instanceId: string, + payload: Record, + ): Promise<{ status: string; data?: unknown; message?: string }> { + const inst = await this.resolveInstance(licenseId, instanceId); + const res = await this.nats.requestScoped<{ status: string; data?: unknown; message?: string }>( + licenseId, + this.filesSubject(inst, licenseId), + payload, + 12_000, + ); + if (res?.status === 'error') { + throw new BadRequestException(res.message ?? 'File operation failed'); + } + return res; + } + + async listFiles(licenseId: string, instanceId: string, path = ''): Promise { + const res = await this.fileOp(licenseId, instanceId, { op: 'list', path }); + return res.data; + } + + async readFile(licenseId: string, instanceId: string, path: string): Promise { + if (!path) throw new BadRequestException('path is required'); + const res = await this.fileOp(licenseId, instanceId, { op: 'read', path }); + return res.data; + } + + async writeFile( + licenseId: string, + instanceId: string, + path: string, + content: string, + ): Promise { + if (!path) throw new BadRequestException('path is required'); + const res = await this.fileOp(licenseId, instanceId, { op: 'write', path, content }); + return res.data ?? { status: 'success' }; + } }