feat: add companion API and Android APK shell

This commit is contained in:
Hermes Agent
2026-07-09 04:43:00 +00:00
parent bd0cf34aa3
commit 7eb9648eb7
92 changed files with 7907 additions and 877 deletions
+35
View File
@@ -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' }));
}
+37
View File
@@ -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,
}),
);
});
}
+117
View File
@@ -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(),
});
});
}
+6 -1
View File
@@ -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);
+25
View File
@@ -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,
});
});
}