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
2 changes: 2 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fastify, type FastifyBaseLogger } from 'fastify';
import { getConnectionToken } from './connection-token.js';
import { getDataDir } from './data-dir.js';
import { setHostServerPort } from './host-port.js';
import deviceFilesRoutes from './modules/agent/acp/device-files.route.js';
import { createHarnessDiscoveryRoutes } from './modules/agent/acp/harness-discovery.route.js';
import { registerHarnessProfileDiscovery } from './modules/agent/acp/harness-profile-discovery.js';
import {
Expand Down Expand Up @@ -367,6 +368,7 @@ app.addHook('onListen', async () => {
// (M3). See modules/agent/acp/profile-cache-port.ts.
installAcpProfileCachePort();
app.register(acpProfilesRoutes, { prefix: '/api/acp' });
app.register(deviceFilesRoutes, { prefix: '/api/acp' });
app.register(agentDefaultsRoutes, { prefix: '/api/agent/defaults' });
app.register(acpAgentletRoutes, { prefix: '/api/acp' });
app.register(acpAgentCliRoutes, { prefix: '/api/acp' });
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/modules/agent/acp/agent-cli.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ export function createAcpAgentCliRoutes(
message: 'Agent Profile is unavailable',
});
}
return { agents: await detect(parsed.data) };
return {
agents: (await detect(parsed.data)).filter(
(entry) => entry.id !== 'proxy',
),
};
} catch (error) {
request.log.warn(
{ err: error },
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/modules/agent/acp/device-files.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { deviceFilesBodySchema } from '@huabu/shared';

import { DeviceFilesError, DeviceFilesService } from './device-files.js';
import { isOwnerRequest } from '../../security/owner.js';

import type { FastifyPluginAsync } from 'fastify';

const deviceFilesRoutes: FastifyPluginAsync = async (app) => {
let service: DeviceFilesService | undefined;
app.addHook('preClose', async () => service?.dispose());
app.post('/files', async (request, reply) => {
if (!isOwnerRequest(request))
return reply
.status(403)
.send({ message: 'Owner authorization required' });
const parsed = deviceFilesBodySchema.safeParse(request.body);
if (!parsed.success)
return reply.status(400).send({
code: 'invalid_request',
message: 'Invalid filesystem request',
});
reply.header('Cache-Control', 'no-store');
try {
service ??= new DeviceFilesService(app.log);
return await service.execute(parsed.data);
} catch (error) {
request.log.warn(
{
code:
error instanceof DeviceFilesError
? error.code
: 'files_unavailable',
},
'Device file request failed',
);
return reply
.status(error instanceof DeviceFilesError ? error.status : 503)
.send({
code:
error instanceof DeviceFilesError
? error.code
: 'files_unavailable',
message:
error instanceof DeviceFilesError
? error.message
: 'Filesystem service unavailable',
});
}
});
};
export default deviceFilesRoutes;
305 changes: 305 additions & 0 deletions apps/server/src/modules/agent/acp/device-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import Fastify from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DeviceFilesService } from './device-files.js';
import deviceFilesRoutes from './device-files.route.js';

import type { AcpAgentClient } from '@agenetes/acp-driver';

const mocks = vi.hoisted(() => ({
ensure: vi.fn(),
release: vi.fn(),
remove: vi.fn(),
shutdown: vi.fn(),
prompt: vi.fn(),
permission: vi.fn(),
listener: undefined as
| undefined
| ((event: {
agentletId: string;
status: 'connected' | 'disconnected';
}) => void),
connected: true,
profiles: [] as Array<Record<string, unknown>>,
}));
vi.mock('@agenetes/acp-driver', () => ({
ensureAcpSession: mocks.ensure,
releaseThread: mocks.release,
acpSessionRegistry: { remove: mocks.remove },
}));
vi.mock('@agenetes/agentlet-host', () => ({
getAgentProfileRegistry: () => ({ listProfiles: () => mocks.profiles }),
getAgentletGateway: () => ({
getAgentlet: (id: string) =>
id === 'device' && mocks.connected ? { status: 'connected' } : undefined,
onAgentletsChanged: (listener: typeof mocks.listener) => {
mocks.listener = listener;
return vi.fn();
},
}),
}));

const listing = {
version: 1,
operation: 'fs-list',
ok: true,
result: { path: '/work', parentPath: '/', entries: [] },
};
const request = {
agentletId: 'device',
operation: 'fs-list' as const,
args: { path: '/work' },
};
let service: DeviceFilesService;
beforeEach(() => {
vi.clearAllMocks();
mocks.connected = true;
mocks.profiles = [
{
id: 'proxy-profile',
alias: 'Filesystem Proxy',
agentletId: 'device',
visible: false,
launch: { kind: 'acp-harness', harnessId: 'proxy' },
workingDirPath: '/neutral',
customData: {
discoveredAgent: {
version: 1,
agentletId: 'device',
harnessId: 'proxy',
},
},
},
];
mocks.release.mockResolvedValue(undefined);
mocks.prompt.mockImplementation(
async (...args: Parameters<AcpAgentClient['prompt']>) => {
args[2]({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: JSON.stringify(listing) },
});
return { stopReason: 'end_turn' };
},
);
mocks.ensure.mockResolvedValue({
sessionId: 'session',
availableCommands: [{ name: 'fs-list' }, { name: 'fs-read' }],
client: {
isClosed: false,
prompt: mocks.prompt,
shutdown: mocks.shutdown,
resolvePermission: mocks.permission,
},
});
service = new DeviceFilesService({ warn: vi.fn(), error: vi.fn() });
});
afterEach(async () => {
await service.dispose();
vi.useRealTimers();
});

describe('private filesystem ACP service', () => {
it('validates owner-only HTTP input and marks successful file responses no-store', async () => {
const app = Fastify();
await app.register(deviceFilesRoutes, { prefix: '/api/acp' });
try {
expect(
(
await app.inject({
method: 'POST',
url: '/api/acp/files',
remoteAddress: '203.0.113.5',
payload: request,
})
).statusCode,
).toBe(403);
expect(
(
await app.inject({
method: 'POST',
url: '/api/acp/files',
payload: { ...request, args: { path: 'relative' } },
})
).statusCode,
).toBe(400);
expect(mocks.ensure).not.toHaveBeenCalled();
const response = await app.inject({
method: 'POST',
url: '/api/acp/files',
payload: request,
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({
operation: 'fs-list',
result: listing.result,
});
} finally {
await app.close();
}
});
it('reuses one no-history session with fixed cwd and sends only the command', async () => {
expect(await service.execute(request)).toEqual({
operation: 'fs-list',
result: listing.result,
});
await service.execute(request);
expect(mocks.ensure).toHaveBeenCalledOnce();
expect(mocks.ensure).toHaveBeenCalledWith(
expect.objectContaining({
workloadType: 'Job',
namespace: { name: '' },
env: {},
cwd: '/neutral',
recipe: expect.objectContaining({
autoRestart: false,
launch: { kind: 'acp-harness', harnessId: 'proxy' },
}),
}),
);
expect(mocks.prompt.mock.calls[0]?.[1]).toEqual([
{ type: 'text', text: '/fs-list {"path":"/work"}' },
]);
});

it('requires exact connected device and an automatic proxy identity', async () => {
await expect(
service.execute({ ...request, agentletId: 'hostname' }),
).rejects.toMatchObject({ code: 'files_disconnected' });
mocks.profiles[0]!.agentletId = 'other-device';

Check warning on line 172 in apps/server/src/modules/agent/acp/device-files.test.ts

View workflow job for this annotation

GitHub Actions / Lint / Format / Typecheck / Test / Build

Forbidden non-null assertion
await expect(service.execute(request)).rejects.toMatchObject({
code: 'files_proxy_missing',
});
expect(mocks.ensure).not.toHaveBeenCalled();
});

it('serializes overlapping calls and denies unexpected permissions', async () => {
let active = 0;
mocks.prompt.mockImplementation(
async (...args: Parameters<AcpAgentClient['prompt']>) => {
expect(++active).toBe(1);
args[4]?.({ requestId: 'unexpected', options: [], toolCall: {} });
await new Promise((resolve) => setImmediate(resolve));
args[2]({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: JSON.stringify(listing) },
});
active--;
return { stopReason: 'end_turn' };
},
);
await Promise.all([service.execute(request), service.execute(request)]);
expect(mocks.permission).toHaveBeenCalledWith('unexpected', {
cancelled: true,
});
});

it('rejects late replies after connection generation changes', async () => {
let finish!: () => void;
mocks.prompt.mockImplementation(
() =>
new Promise((resolve) => {
finish = () => resolve({ stopReason: 'end_turn' });
}),
);
const pending = service.execute(request);
await vi.waitFor(() => expect(mocks.prompt).toHaveBeenCalledOnce());
mocks.listener?.({ agentletId: 'device', status: 'disconnected' });
finish();
await expect(pending).rejects.toMatchObject({ code: 'files_disconnected' });
expect(mocks.shutdown).toHaveBeenCalled();
expect(mocks.release).toHaveBeenCalled();
});

it.each([
'prose',
JSON.stringify({ ...listing, version: 2 }),
JSON.stringify({ ...listing, operation: 'fs-read' }),
])('rejects malformed or mismatched output %s', async (text) => {
mocks.prompt.mockImplementation(
async (...args: Parameters<AcpAgentClient['prompt']>) => {
args[2]({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
return { stopReason: 'end_turn' };
},
);
await expect(service.execute(request)).rejects.toMatchObject({
status: 502,
});
});
it('does not interpret end_turn as filesystem success', async () => {
mocks.prompt.mockImplementation(
async (...args: Parameters<AcpAgentClient['prompt']>) => {
args[2]({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: JSON.stringify({
version: 1,
operation: 'fs-list',
ok: false,
error: {
code: 'permission_denied',
message: 'Permission denied',
},
}),
},
});
return { stopReason: 'end_turn' };
},
);
await expect(service.execute(request)).rejects.toMatchObject({
code: 'permission_denied',
status: 403,
});
});

it('retains admission ownership for timed-out startup and reclaims a late process', async () => {
vi.useFakeTimers();
const entry = await mocks.ensure();
mocks.ensure.mockClear();
let complete!: (value: typeof entry) => void;
mocks.ensure.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const pending = service.execute(request);
const rejected = expect(pending).rejects.toMatchObject({
code: 'files_timeout',
});
await vi.advanceTimersByTimeAsync(60_000);
await rejected;
await expect(service.execute(request)).rejects.toMatchObject({
code: 'files_busy',
});
expect(mocks.ensure).toHaveBeenCalledOnce();
complete(entry);
await vi.advanceTimersByTimeAsync(0);
expect(mocks.shutdown).toHaveBeenCalled();
expect(mocks.release).toHaveBeenCalled();
await expect(service.execute(request)).resolves.toMatchObject({
operation: 'fs-list',
});
});

it('surfaces cleanup failure and retries reclamation before admitting another process', async () => {
await service.execute(request);
mocks.release.mockRejectedValueOnce(new Error('Device stop failed'));
mocks.prompt.mockRejectedValueOnce(new Error('Transport lost'));
await expect(service.execute(request)).rejects.toMatchObject({
code: 'files_cleanup_failed',
});
await expect(service.execute(request)).resolves.toMatchObject({
operation: 'fs-list',
});
expect(mocks.release).toHaveBeenCalledTimes(2);
expect(mocks.ensure).toHaveBeenCalledTimes(2);
});
});
Loading
Loading