Skip to content
24 changes: 22 additions & 2 deletions mobile/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,13 @@ type ServerMode = 'real' | 'demo';
interface ServerConfig {
host: string;
port: number;
token?: string;
mode?: ServerMode;
}

let baseUrl = '';
let serverMode: ServerMode = 'real';
let currentToken: string | undefined;

export function setBaseUrl(url: string): void {
baseUrl = url;
Expand Down Expand Up @@ -173,32 +175,50 @@ export async function loadServerConfig(): Promise<ServerConfig | null> {
const mode = resolveMode(config.host, config.mode);

baseUrl = `http://${normalizeHost(config.host)}:${config.port}`;
currentToken = config.token;
client = createClient();
setServerMode(mode);
setUserContext(baseUrl);

return { ...config, mode };
}

export async function saveServerConfig(host: string, port: number = DEFAULT_PORT): Promise<void> {
export async function saveServerConfig(
host: string,
port: number = DEFAULT_PORT,
token?: string
): Promise<void> {
const mode = resolveMode(host);
const config: ServerConfig = { host, port, mode };
const config: ServerConfig = { host, port, token, mode };

await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(config));

baseUrl = `http://${normalizeHost(host)}:${port}`;
currentToken = token;
client = createClient();
setServerMode(mode);
setUserContext(baseUrl);
}

export function getToken(): string | undefined {
return currentToken;
}

export function getDefaultPort(): number {
return DEFAULT_PORT;
}

function createClient() {
const token = currentToken;
const link = new RPCLink({
url: `${baseUrl}/rpc`,
fetch: (url, init) => {
const headers = new Headers((init as RequestInit)?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(url, { ...(init as RequestInit), headers });
},
});

return createORPCClient<{
Expand Down
55 changes: 55 additions & 0 deletions src/agent/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { AgentConfig } from '../shared/types';
import { getTailscaleIdentity } from '../tailscale';

export interface AuthResult {
ok: boolean;
identity?: { type: 'token' | 'tailscale'; user?: string };
}

const PUBLIC_PATHS = ['/health'];

const WEB_UI_PATTERNS = [/^\/$/, /^\/index\.html$/, /^\/assets\//, /^\/favicon\.ico$/];

function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.includes(pathname)) {
return true;
}
return WEB_UI_PATTERNS.some((pattern) => pattern.test(pathname));
}

export function checkAuth(req: Request, config: AgentConfig): AuthResult {
const url = new URL(req.url);

if (isPublicPath(url.pathname)) {
return { ok: true };
}

if (!config.auth?.token) {
return { ok: true };
}

const tsIdentity = getTailscaleIdentity(req);
if (tsIdentity) {
return { ok: true, identity: { type: 'tailscale', user: tsIdentity.email } };
}

const authHeader = req.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
if (token === config.auth.token) {
return { ok: true, identity: { type: 'token' } };
}
}

return { ok: false };
}

export function unauthorizedResponse(): Response {
return new Response('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer',
'Access-Control-Allow-Origin': '*',
},
});
}
45 changes: 37 additions & 8 deletions src/agent/run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Server, ServerWebSocket } from 'bun';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { RPCHandler } from '@orpc/server/fetch';
import { loadAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
import { loadAgentConfig, saveAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
import type { AgentConfig } from '../shared/types';
import { CONFIG_FILE } from '../shared/types';
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
import { DEFAULT_AGENT_PORT } from '../shared/constants';
import { WorkspaceManager } from '../workspace/manager';
Expand All @@ -14,6 +18,7 @@ import { serveStaticBun } from './static';
import { SessionsCacheManager } from '../sessions/cache';
import { ModelCacheManager } from '../models/cache';
import { FileWatcher } from './file-watcher';
import { checkAuth, unauthorizedResponse } from './auth';
import {
getTailscaleStatus,
getTailscaleIdentity,
Expand Down Expand Up @@ -125,13 +130,23 @@ function createAgentServer(
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};

if (method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}

const staticResponse = await serveStaticBun(pathname);
if (staticResponse) {
return staticResponse;
}

const authResult = checkAuth(req, currentConfig);
if (!authResult.ok) {
return unauthorizedResponse();
}
Comment on lines +145 to +148

This comment was marked as outdated.


const terminalMatch = pathname.match(/^\/rpc\/terminal\/([^/]+)$/);

if (terminalMatch) {
Expand Down Expand Up @@ -177,11 +192,6 @@ function createAgentServer(
}
}

const staticResponse = await serveStaticBun(pathname);
if (staticResponse) {
return staticResponse;
}

return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders });
},

Expand Down Expand Up @@ -252,12 +262,31 @@ const BANNER = `
|_| |_____|_| \\_\\_| \\_\\|_|
`;

async function ensureAuthForNewInstalls(configDir: string): Promise<AgentConfig> {
const configPath = path.join(configDir, CONFIG_FILE);
const configExists = fs.existsSync(configPath);

const config = await loadAgentConfig(configDir);

if (!configExists && !config.auth?.token) {
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
config.auth = { ...config.auth, token };
await saveAgentConfig(config, configDir);
Comment on lines +267 to +274

This comment was marked as outdated.


console.log(`[agent] New install detected - auth enabled by default`);
console.log(`[agent] Auth token generated: ${token}`);
console.log(`[agent] Configure clients with: perry config token ${token}`);
}

return config;
}

export async function startAgent(options: StartAgentOptions = {}): Promise<void> {
const configDir = options.configDir || getConfigDir();

await ensureConfigDir(configDir);

const config = await loadAgentConfig(configDir);
const config = await ensureAuthForNewInstalls(configDir);

if (options.noHostAccess || process.env.PERRY_NO_HOST_ACCESS === 'true') {
config.allowHostAccess = false;
Expand Down
10 changes: 10 additions & 0 deletions src/agent/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export async function serveStatic(
return true;
}

const API_PREFIXES = ['/rpc', '/health'];

function isApiPath(pathname: string): boolean {
return API_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}

export async function serveStaticBun(pathname: string): Promise<Response | null> {
const webDir = getWebDir();
const indexPath = path.join(webDir, 'index.html');
Expand Down Expand Up @@ -106,6 +112,10 @@ export async function serveStaticBun(pathname: string): Promise<Response | null>
}
}

if (isApiPath(pathname)) {
return null;
}

const file = Bun.file(indexPath);
return new Response(file, {
headers: { 'Content-Type': 'text/html' },
Expand Down
33 changes: 28 additions & 5 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { DEFAULT_AGENT_PORT } from '../shared/constants';
export interface ApiClientOptions {
baseUrl: string;
timeout?: number;
token?: string;
}

export class ApiClientError extends Error {
Expand All @@ -34,21 +35,38 @@ type Client = RouterClient<AppRouter>;
export class ApiClient {
private baseUrl: string;
private client: Client;
private token?: string;

constructor(options: ApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.token = options.token;

const token = this.token;
const timeout = options.timeout || 30000;
const link = new RPCLink({
url: `${this.baseUrl}/rpc`,
fetch: (url, init) =>
fetch(url, { ...init, signal: AbortSignal.timeout(options.timeout || 30000) }),
fetch: (url, init) => {
const headers = new Headers((init as RequestInit)?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(url, {
...(init as RequestInit),
headers,
signal: AbortSignal.timeout(timeout),
});
},
});

this.client = createORPCClient<Client>(link);
}

async health(): Promise<{ status: string; version: string }> {
const response = await fetch(`${this.baseUrl}/health`);
const headers: Record<string, string> = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(`${this.baseUrl}/health`, { headers });
return response.json();
}

Expand Down Expand Up @@ -257,7 +275,12 @@ export function formatWorkerBaseUrl(worker: string, port?: number): string {
return `http://${trimmed}:${effectivePort}`;
}

export function createApiClient(worker: string, port?: number, timeoutMs?: number): ApiClient {
export function createApiClient(
worker: string,
port?: number,
timeoutMs?: number,
token?: string
): ApiClient {
const baseUrl = formatWorkerBaseUrl(worker, port);
return new ApiClient({ baseUrl, timeout: timeoutMs });
return new ApiClient({ baseUrl, timeout: timeoutMs, token });
}
11 changes: 11 additions & 0 deletions src/client/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ export async function setAgent(agent: string, configDir?: string): Promise<void>
await saveClientConfig(config, configDir);
}

export async function getToken(configDir?: string): Promise<string | null> {
const config = await loadClientConfig(configDir);
return config?.token || null;
}

export async function setToken(token: string, configDir?: string): Promise<void> {
const config = (await loadClientConfig(configDir)) || {};
config.token = token;
await saveClientConfig(config, configDir);
}

// Legacy aliases for backwards compatibility
export const getWorker = getAgent;
export const setWorker = setAgent;
21 changes: 21 additions & 0 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import crypto from 'crypto';
import { loadAgentConfig, saveAgentConfig, getConfigDir } from '../config/loader';

export async function authInit(): Promise<void> {
const configDir = getConfigDir();
const config = await loadAgentConfig(configDir);

if (config.auth?.token) {
console.log('Auth token already exists.');
console.log('To regenerate, remove auth.token from config.json first.');
return;
}

const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
config.auth = { ...config.auth, token };
await saveAgentConfig(config, configDir);

console.log(`Auth token generated: ${token}`);
console.log(`Configure clients with: perry config token ${token}`);
console.log('Restart the agent for auth to take effect.');
}
1 change: 1 addition & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export async function loadAgentConfig(configDir?: string): Promise<AgentConfig>
workspaces: config.ssh?.workspaces || {},
},
tailscale,
auth: config.auth,
};
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
Expand Down
Loading
Loading