feat: add companion API and Android APK shell
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
@@ -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 +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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user