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
+26 -1
View File
@@ -1 +1,26 @@
{"name":"@hermes-mobile/companion","version":"0.1.0","private":true,"type":"module","main":"dist/index.js","scripts":{"dev":"tsx watch src/index.ts","build":"tsc -p tsconfig.json","start":"node dist/index.js","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"@fastify/cors":"^10.0.1","@hermes-mobile/shared":"workspace:*","fastify":"^5.2.1"},"devDependencies":{"tsx":"^4.19.2"}}
{
"name": "@hermes-mobile/companion",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src",
"setup": "tsx src/cli.ts setup"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@hermes-mobile/shared": "file:../../packages/shared",
"fastify": "^5.2.1"
},
"devDependencies": {
"tsx": "^4.19.2"
},
"bin": {
"hermes-mobile-companion": "dist/cli.js"
}
}
+15 -2
View File
@@ -1,8 +1,15 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import type { CompanionConfig } from './config/companionConfig.js';
import { getRuntimeConfig } from './config/companionConfig.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerChatRoutes } from './routes/chat.js';
import { registerFileRoutes } from './routes/files.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminal.js';
export async function buildApp() {
export async function buildApp(runtimeConfig?: CompanionConfig) {
const config = runtimeConfig ?? (await getRuntimeConfig());
const app = Fastify({ logger: true });
await app.register(cors, {
@@ -12,9 +19,15 @@ export async function buildApp() {
app.get('/', async () => ({
service: 'hermes-mobile-companion',
health: '/api/health',
auth: '/api/auth/validate',
workspaceRoot: config.workspaceRoot,
}));
await registerHealthRoutes(app);
await registerAuthRoutes(app, config);
await registerHealthRoutes(app, config);
await registerChatRoutes(app, config);
await registerFileRoutes(app, config);
await registerTerminalRoutes(app, config);
return app;
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import { buildApp } from './app.js';
import { ensureCompanionConfig, getConfigPath, getRuntimeConfig } from './config/companionConfig.js';
async function runSetup(): Promise<void> {
const config = await ensureCompanionConfig();
console.log('Hermes Mobile companion setup complete.');
console.log(`Config: ${getConfigPath()}`);
console.log(`Workspace root: ${config.workspaceRoot}`);
console.log(`Access key: ${config.accessKey}`);
console.log('Use this key in the Android app Settings screen.');
}
async function runServer(): Promise<void> {
const port = Number.parseInt(process.env.PORT ?? '8787', 10);
const host = process.env.HOST ?? '0.0.0.0';
const config = await getRuntimeConfig();
if (!config.accessKey) {
console.error('No access key configured. Run `npm run companion:setup` or set HERMES_MOBILE_ACCESS_KEY.');
process.exit(1);
}
const app = await buildApp(config);
try {
await app.listen({ port, host });
} catch (error) {
app.log.error(error);
process.exit(1);
}
}
const command = process.argv[2] ?? 'start';
if (command === 'setup') {
await runSetup();
} else if (command === 'start' || command === 'serve') {
await runServer();
} else {
console.error(`Unknown command: ${command}`);
console.error('Usage: hermes-mobile-companion setup|start');
process.exit(1);
}
@@ -0,0 +1,61 @@
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
export type CompanionConfig = {
accessKey: string;
workspaceRoot: string;
};
export function getConfigPath(): string {
return process.env.HERMES_MOBILE_CONFIG ?? resolve(homedir(), '.config/hermes-mobile/companion.json');
}
export function getWorkspaceRoot(): string {
return resolve(process.env.HERMES_MOBILE_WORKSPACE_ROOT ?? process.cwd());
}
export function generateAccessKey(): string {
return `hm_${randomBytes(24).toString('base64url')}`;
}
export async function readCompanionConfig(): Promise<Partial<CompanionConfig>> {
const configPath = getConfigPath();
if (!existsSync(configPath)) {
return {};
}
const rawConfig = await readFile(configPath, 'utf8');
const parsedConfig = JSON.parse(rawConfig) as Partial<CompanionConfig>;
return parsedConfig;
}
export async function writeCompanionConfig(config: CompanionConfig): Promise<void> {
const configPath = getConfigPath();
await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
}
export async function ensureCompanionConfig(): Promise<CompanionConfig> {
const existingConfig = await readCompanionConfig();
const config: CompanionConfig = {
accessKey: existingConfig.accessKey || process.env.HERMES_MOBILE_ACCESS_KEY || generateAccessKey(),
workspaceRoot: resolve(existingConfig.workspaceRoot || getWorkspaceRoot()),
};
await writeCompanionConfig(config);
return config;
}
export async function getRuntimeConfig(): Promise<CompanionConfig> {
const existingConfig = await readCompanionConfig();
return {
accessKey: process.env.HERMES_MOBILE_ACCESS_KEY || existingConfig.accessKey || '',
workspaceRoot: resolve(process.env.HERMES_MOBILE_WORKSPACE_ROOT || existingConfig.workspaceRoot || process.cwd()),
};
}
+27
View File
@@ -0,0 +1,27 @@
import { isAbsolute, relative, resolve, sep } from 'node:path';
const ignoredPathSegments = new Set(['.git', '.dev', 'node_modules', 'dist', 'android']);
export function toSafeAbsolutePath(workspaceRoot: string, requestedPath = '.'): string {
const root = resolve(workspaceRoot);
const target = resolve(root, requestedPath || '.');
const relativePath = relative(root, target);
if (relativePath === '' || (!relativePath.startsWith('..') && !relativePath.includes(`..${sep}`) && !isAbsolute(relativePath))) {
return target;
}
throw new Error('Path is outside the configured workspace root');
}
export function toWorkspacePath(workspaceRoot: string, absolutePath: string): string {
const relativePath = relative(resolve(workspaceRoot), resolve(absolutePath));
return relativePath === '' ? '.' : relativePath.split(sep).join('/');
}
export function isIgnoredWorkspacePath(workspacePath: string): boolean {
return workspacePath
.split('/')
.filter(Boolean)
.some((segment) => ignoredPathSegments.has(segment));
}
+1 -13
View File
@@ -1,13 +1 @@
import { buildApp } from './app.js';
const port = Number.parseInt(process.env.PORT ?? '8787', 10);
const host = process.env.HOST ?? '0.0.0.0';
const app = await buildApp();
try {
await app.listen({ port, host });
} catch (error) {
app.log.error(error);
process.exit(1);
}
import './cli.js';
+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,
});
});
}
+49
View File
@@ -0,0 +1,49 @@
import { spawn } from 'node:child_process';
export type RunProcessResult = {
stdout: string;
stderr: string;
exitCode: number | null;
timedOut: boolean;
};
export function runProcess(command: string, args: string[], options: { cwd: string; timeoutMs: number; input?: string }): Promise<RunProcessResult> {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd,
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 1000).unref();
}, options.timeoutMs);
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (error) => {
stderr += error.message;
});
child.on('close', (exitCode) => {
clearTimeout(timeout);
resolve({ stdout, stderr, exitCode, timedOut });
});
if (options.input) {
child.stdin.write(options.input);
}
child.stdin.end();
});
}
+17 -1
View File
@@ -1 +1,17 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"outDir":"dist","rootDir":"src","noEmit":false},"references":[{"path":"../../packages/shared"}],"include":["src"]}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": false,
"composite": true
},
"references": [
{
"path": "../../packages/shared"
}
],
"include": [
"src"
]
}