feat: add companion API and Android APK shell
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { authValidateResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
|
||||
function safeCompare(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
export function hasValidBearer(request: FastifyRequest, accessKey: string): boolean {
|
||||
const header = request.headers.authorization;
|
||||
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return accessKey.length > 0 && safeCompare(header.slice('Bearer '.length).trim(), accessKey);
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
if (request.routeOptions.url === '/' || request.routeOptions.url === '/api/health') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasValidBearer(request, config.accessKey)) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/validate', async () => authValidateResponseSchema.parse({ ok: true, message: 'Access key accepted' }));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { chatRequestSchema, chatResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { toSafeAbsolutePath } from '../config/paths.js';
|
||||
import { getHermesHealth } from '../system/hermesHealth.js';
|
||||
import { runProcess } from '../system/processes.js';
|
||||
|
||||
export async function registerChatRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.post('/api/chat', async (request, reply) => {
|
||||
const body = chatRequestSchema.parse(request.body);
|
||||
const hermesHealth = await getHermesHealth();
|
||||
|
||||
if (!hermesHealth.cliAvailable) {
|
||||
return chatResponseSchema.parse({
|
||||
reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.',
|
||||
exitCode: null,
|
||||
stderr: undefined,
|
||||
hermesAvailable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runProcess(hermesHealth.cliPath ?? 'hermes', [], {
|
||||
cwd: toSafeAbsolutePath(config.workspaceRoot),
|
||||
timeoutMs: 30000,
|
||||
input: body.prompt,
|
||||
});
|
||||
|
||||
return reply.code(result.exitCode === 0 || result.exitCode === null ? 200 : 502).send(
|
||||
chatResponseSchema.parse({
|
||||
reply: result.stdout.trim() || result.stderr.trim() || 'Hermes finished without output.',
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr || undefined,
|
||||
hermesAvailable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
fileListResponseSchema,
|
||||
fileMetadataResponseSchema,
|
||||
fileReadResponseSchema,
|
||||
fileWriteRequestSchema,
|
||||
fileWriteResponseSchema,
|
||||
} from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { isIgnoredWorkspacePath, toSafeAbsolutePath, toWorkspacePath } from '../config/paths.js';
|
||||
|
||||
function getQueryPath(query: unknown): string {
|
||||
if (typeof query === 'object' && query !== null && 'path' in query && typeof query.path === 'string') {
|
||||
return query.path;
|
||||
}
|
||||
|
||||
return '.';
|
||||
}
|
||||
|
||||
export async function registerFileRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
function ensureVisibleWorkspacePath(absolutePath: string): string {
|
||||
const workspacePath = toWorkspacePath(config.workspaceRoot, absolutePath);
|
||||
|
||||
if (isIgnoredWorkspacePath(workspacePath)) {
|
||||
throw new Error('Path is hidden from the mobile file explorer');
|
||||
}
|
||||
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
app.get('/api/files', async (request) => {
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, getQueryPath(request.query));
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
const hydratedEntries = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const absoluteEntryPath = join(targetPath, entry.name);
|
||||
const entryWorkspacePath = toWorkspacePath(config.workspaceRoot, absoluteEntryPath);
|
||||
|
||||
if (isIgnoredWorkspacePath(entryWorkspacePath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metadata = await stat(absoluteEntryPath);
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entryWorkspacePath,
|
||||
type: entry.isDirectory() ? ('directory' as const) : ('file' as const),
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const visibleEntries = hydratedEntries.filter((entry) => entry !== undefined);
|
||||
|
||||
visibleEntries.sort((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
return fileListResponseSchema.parse({
|
||||
path: workspacePath,
|
||||
entries: visibleEntries,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/files/read', async (request) => {
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, getQueryPath(request.query));
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
const metadata = await stat(targetPath);
|
||||
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error('Only files can be read');
|
||||
}
|
||||
|
||||
return fileReadResponseSchema.parse({
|
||||
path: workspacePath,
|
||||
content: await readFile(targetPath, 'utf8'),
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/files/write', async (request) => {
|
||||
const body = fileWriteRequestSchema.parse(request.body);
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, body.path);
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, body.content, 'utf8');
|
||||
|
||||
return fileWriteResponseSchema.parse({
|
||||
ok: true,
|
||||
path: workspacePath,
|
||||
bytesWritten: Buffer.byteLength(body.content, 'utf8'),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/files/metadata', async (request) => {
|
||||
const targetPath = toSafeAbsolutePath(config.workspaceRoot, getQueryPath(request.query));
|
||||
const workspacePath = ensureVisibleWorkspacePath(targetPath);
|
||||
const metadata = await stat(targetPath);
|
||||
|
||||
return fileMetadataResponseSchema.parse({
|
||||
path: workspacePath,
|
||||
name: basename(targetPath),
|
||||
type: metadata.isDirectory() ? 'directory' : 'file',
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.mtime.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { healthResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { getHermesHealth } from '../system/hermesHealth.js';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
|
||||
export async function registerHealthRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.get('/api/health', async () => {
|
||||
const response = {
|
||||
ok: true,
|
||||
@@ -14,6 +15,10 @@ export async function registerHealthRoutes(app: FastifyInstance): Promise<void>
|
||||
checks: {
|
||||
hermes: await getHermesHealth(),
|
||||
},
|
||||
config: {
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
authConfigured: config.accessKey.length > 0,
|
||||
},
|
||||
};
|
||||
|
||||
return healthResponseSchema.parse(response);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { terminalRunRequestSchema, terminalRunResponseSchema } from '@hermes-mobile/shared';
|
||||
import type { CompanionConfig } from '../config/companionConfig.js';
|
||||
import { toSafeAbsolutePath, toWorkspacePath } from '../config/paths.js';
|
||||
import { runProcess } from '../system/processes.js';
|
||||
|
||||
export async function registerTerminalRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
|
||||
app.post('/api/terminal/run', async (request) => {
|
||||
const body = terminalRunRequestSchema.parse(request.body);
|
||||
const cwd = toSafeAbsolutePath(config.workspaceRoot, body.cwd ?? '.');
|
||||
const result = await runProcess('/bin/sh', ['-lc', body.command], {
|
||||
cwd,
|
||||
timeoutMs: body.timeoutMs ?? 10000,
|
||||
});
|
||||
|
||||
return terminalRunResponseSchema.parse({
|
||||
cwd: toWorkspacePath(config.workspaceRoot, cwd),
|
||||
command: body.command,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user