@sooneocean/agw 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +116 -0
  2. package/bin/agw.ts +5 -0
  3. package/package.json +59 -0
  4. package/src/agents/base-adapter.ts +113 -0
  5. package/src/agents/claude-adapter.ts +29 -0
  6. package/src/agents/codex-adapter.ts +29 -0
  7. package/src/agents/gemini-adapter.ts +29 -0
  8. package/src/cli/commands/agents.ts +55 -0
  9. package/src/cli/commands/combo.ts +130 -0
  10. package/src/cli/commands/costs.ts +33 -0
  11. package/src/cli/commands/daemon.ts +110 -0
  12. package/src/cli/commands/history.ts +29 -0
  13. package/src/cli/commands/run.ts +59 -0
  14. package/src/cli/commands/status.ts +29 -0
  15. package/src/cli/commands/workflow.ts +73 -0
  16. package/src/cli/error-handler.ts +8 -0
  17. package/src/cli/http-client.ts +68 -0
  18. package/src/cli/index.ts +28 -0
  19. package/src/config.ts +68 -0
  20. package/src/daemon/middleware/auth.ts +35 -0
  21. package/src/daemon/middleware/rate-limiter.ts +63 -0
  22. package/src/daemon/middleware/tenant.ts +64 -0
  23. package/src/daemon/middleware/workspace.ts +40 -0
  24. package/src/daemon/routes/agents.ts +13 -0
  25. package/src/daemon/routes/combos.ts +103 -0
  26. package/src/daemon/routes/costs.ts +9 -0
  27. package/src/daemon/routes/health.ts +62 -0
  28. package/src/daemon/routes/memory.ts +32 -0
  29. package/src/daemon/routes/tasks.ts +157 -0
  30. package/src/daemon/routes/ui.ts +18 -0
  31. package/src/daemon/routes/workflows.ts +73 -0
  32. package/src/daemon/server.ts +91 -0
  33. package/src/daemon/services/agent-learning.ts +77 -0
  34. package/src/daemon/services/agent-manager.ts +71 -0
  35. package/src/daemon/services/auto-scaler.ts +77 -0
  36. package/src/daemon/services/circuit-breaker.ts +95 -0
  37. package/src/daemon/services/combo-executor.ts +300 -0
  38. package/src/daemon/services/dag-executor.ts +136 -0
  39. package/src/daemon/services/metrics.ts +35 -0
  40. package/src/daemon/services/stream-aggregator.ts +64 -0
  41. package/src/daemon/services/task-executor.ts +184 -0
  42. package/src/daemon/services/task-queue.ts +75 -0
  43. package/src/daemon/services/workflow-executor.ts +150 -0
  44. package/src/daemon/services/ws-manager.ts +90 -0
  45. package/src/dsl/parser.ts +124 -0
  46. package/src/plugins/plugin-loader.ts +72 -0
  47. package/src/router/keyword-router.ts +63 -0
  48. package/src/router/llm-router.ts +93 -0
  49. package/src/store/agent-repo.ts +57 -0
  50. package/src/store/audit-repo.ts +25 -0
  51. package/src/store/combo-repo.ts +99 -0
  52. package/src/store/cost-repo.ts +55 -0
  53. package/src/store/db.ts +137 -0
  54. package/src/store/memory-repo.ts +46 -0
  55. package/src/store/task-repo.ts +127 -0
  56. package/src/store/workflow-repo.ts +69 -0
  57. package/src/types.ts +208 -0
  58. package/tsconfig.json +17 -0
  59. package/ui/index.html +272 -0
@@ -0,0 +1,110 @@
1
+ import type { Command } from 'commander';
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+
7
+ const AGW_DIR = path.join(os.homedir(), '.agw');
8
+ const PID_FILE = path.join(AGW_DIR, 'daemon.pid');
9
+
10
+ export function registerDaemonCommand(program: Command): void {
11
+ const cmd = program
12
+ .command('daemon')
13
+ .description('Manage the AGW daemon');
14
+
15
+ cmd
16
+ .command('start')
17
+ .description('Start the daemon')
18
+ .option('-d', 'Run as background daemon')
19
+ .option('--port <port>', 'Port to listen on')
20
+ .action(async (options: { d?: boolean; port?: string }) => {
21
+ if (isRunning()) {
22
+ console.log('Daemon is already running.');
23
+ return;
24
+ }
25
+
26
+ if (!fs.existsSync(AGW_DIR)) {
27
+ fs.mkdirSync(AGW_DIR, { recursive: true });
28
+ }
29
+
30
+ if (options.port) {
31
+ process.env.AGW_PORT = options.port;
32
+ }
33
+
34
+ if (options.d) {
35
+ // Daemonize
36
+ const serverPath = path.resolve(import.meta.dirname, '../../daemon/server.js');
37
+ const child = spawn('tsx', [serverPath], {
38
+ detached: true,
39
+ stdio: 'ignore',
40
+ env: { ...process.env },
41
+ });
42
+ child.unref();
43
+ if (child.pid) {
44
+ fs.writeFileSync(PID_FILE, String(child.pid));
45
+ console.log(`Daemon started (PID: ${child.pid})`);
46
+ }
47
+ } else {
48
+ // Foreground
49
+ console.log('Starting daemon in foreground...');
50
+ const { buildServer } = await import('../../daemon/server.js');
51
+ const { loadConfig } = await import('../../config.js');
52
+ const config = loadConfig(path.join(AGW_DIR, 'config.json'));
53
+ const port = options.port ? parseInt(options.port) : config.port;
54
+ const app = await buildServer();
55
+ await app.listen({ port, host: '127.0.0.1' });
56
+ console.log(`AGW daemon listening on http://127.0.0.1:${port}`);
57
+ fs.writeFileSync(PID_FILE, String(process.pid));
58
+
59
+ const cleanup = async () => {
60
+ await app.close();
61
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
62
+ process.exit(0);
63
+ };
64
+ process.on('SIGTERM', cleanup);
65
+ process.on('SIGINT', cleanup);
66
+ }
67
+ });
68
+
69
+ cmd
70
+ .command('stop')
71
+ .description('Stop the daemon')
72
+ .action(() => {
73
+ if (!fs.existsSync(PID_FILE)) {
74
+ console.log('No daemon running.');
75
+ return;
76
+ }
77
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'));
78
+ try {
79
+ process.kill(pid, 'SIGTERM');
80
+ fs.unlinkSync(PID_FILE);
81
+ console.log(`Daemon stopped (PID: ${pid})`);
82
+ } catch {
83
+ fs.unlinkSync(PID_FILE);
84
+ console.log('Daemon was not running (stale PID file removed).');
85
+ }
86
+ });
87
+
88
+ cmd
89
+ .command('status')
90
+ .description('Check daemon status')
91
+ .action(() => {
92
+ if (isRunning()) {
93
+ const pid = fs.readFileSync(PID_FILE, 'utf-8').trim();
94
+ console.log(`Daemon is running (PID: ${pid})`);
95
+ } else {
96
+ console.log('Daemon is not running.');
97
+ }
98
+ });
99
+ }
100
+
101
+ function isRunning(): boolean {
102
+ if (!fs.existsSync(PID_FILE)) return false;
103
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'));
104
+ try {
105
+ process.kill(pid, 0); // Check if process exists
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
@@ -0,0 +1,29 @@
1
+ import type { Command } from 'commander';
2
+ import { HttpClient } from '../http-client.js';
3
+ import type { TaskDescriptor } from '../../types.js';
4
+
5
+ export function registerHistoryCommand(program: Command): void {
6
+ program
7
+ .command('history')
8
+ .description('List recent tasks')
9
+ .option('--limit <n>', 'Number of tasks', '20')
10
+ .action(async (options: { limit: string }) => {
11
+ const client = new HttpClient();
12
+ try {
13
+ const tasks = await client.get<TaskDescriptor[]>(`/tasks?limit=${options.limit}`);
14
+ if (tasks.length === 0) {
15
+ console.log('No tasks yet.');
16
+ return;
17
+ }
18
+ console.log('ID Status Agent Prompt');
19
+ console.log('─'.repeat(60));
20
+ for (const t of tasks) {
21
+ const prompt = t.prompt.length > 30 ? t.prompt.slice(0, 30) + '...' : t.prompt;
22
+ console.log(`${t.taskId} ${t.status.padEnd(10)} ${(t.assignedAgent ?? '-').padEnd(9)} ${prompt}`);
23
+ }
24
+ } catch (err) {
25
+ console.error(`Error: ${(err as Error).message}`);
26
+ process.exit(1);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,59 @@
1
+ import type { Command } from 'commander';
2
+ import { HttpClient } from '../http-client.js';
3
+ import type { TaskDescriptor } from '../../types.js';
4
+
5
+ export function registerRunCommand(program: Command): void {
6
+ program
7
+ .command('run <prompt...>')
8
+ .description('Submit a task to an agent')
9
+ .option('--agent <id>', 'Override agent selection')
10
+ .option('--background', 'Run in background, return taskId')
11
+ .option('--cwd <path>', 'Working directory for the agent')
12
+ .option('--priority <n>', 'Task priority 1-5 (default 3)', '3')
13
+ .action(async (promptParts: string[], options: { agent?: string; background?: boolean; cwd?: string; priority?: string }) => {
14
+ const client = new HttpClient();
15
+ let prompt = promptParts.join(' ');
16
+
17
+ // Read stdin if piped
18
+ if (!process.stdin.isTTY) {
19
+ const chunks: Buffer[] = [];
20
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
21
+ const stdin = Buffer.concat(chunks).toString();
22
+ if (stdin.trim()) prompt = `${prompt}\n\n${stdin}`;
23
+ }
24
+
25
+ try {
26
+ const task = await client.post<TaskDescriptor>('/tasks', {
27
+ prompt,
28
+ preferredAgent: options.agent,
29
+ workingDirectory: options.cwd,
30
+ priority: parseInt(options.priority ?? '3', 10),
31
+ });
32
+
33
+ if (options.background) {
34
+ console.log(`✓ Task submitted taskId: ${task.taskId}`);
35
+ console.log(` Check status: agw status ${task.taskId}`);
36
+ } else {
37
+ if (task.assignedAgent) {
38
+ console.log(`→ ${task.assignedAgent} (${task.routingReason ?? ''})`);
39
+ }
40
+ console.log('─'.repeat(40));
41
+ if (task.result) {
42
+ if (task.result.stdout) console.log(task.result.stdout);
43
+ if (task.result.stderr) console.error(task.result.stderr);
44
+ console.log('─'.repeat(40));
45
+ const tokens = task.result.tokenEstimate ? ` ~${task.result.tokenEstimate} tokens` : '';
46
+ const cost = task.result.costEstimate ? ` ~$${task.result.costEstimate.toFixed(3)}` : '';
47
+ const status = task.result.exitCode === 0 ? '✓ Done' : '✗ Failed';
48
+ console.log(`${status} ${(task.result.durationMs / 1000).toFixed(0)}s${tokens}${cost}`);
49
+ }
50
+ }
51
+ } catch (err) {
52
+ console.error(`Error: ${(err as Error).message}`);
53
+ if ((err as Error).message.includes('fetch failed') || (err as Error).message.includes('ECONNREFUSED')) {
54
+ console.error('Daemon not started. Run: agw daemon start');
55
+ }
56
+ process.exit(1);
57
+ }
58
+ });
59
+ }
@@ -0,0 +1,29 @@
1
+ import type { Command } from 'commander';
2
+ import { HttpClient } from '../http-client.js';
3
+ import type { TaskDescriptor } from '../../types.js';
4
+
5
+ export function registerStatusCommand(program: Command): void {
6
+ program
7
+ .command('status <taskId>')
8
+ .description('Check task status')
9
+ .action(async (taskId: string) => {
10
+ const client = new HttpClient();
11
+ try {
12
+ const task = await client.get<TaskDescriptor>(`/tasks/${taskId}`);
13
+ console.log(`Task: ${task.taskId}`);
14
+ console.log(`Status: ${task.status}`);
15
+ console.log(`Agent: ${task.assignedAgent ?? 'not assigned'}`);
16
+ if (task.result) {
17
+ console.log(`Exit: ${task.result.exitCode}`);
18
+ console.log(`Time: ${(task.result.durationMs / 1000).toFixed(1)}s`);
19
+ if (task.result.stdout) {
20
+ console.log('─'.repeat(40));
21
+ console.log(task.result.stdout);
22
+ }
23
+ }
24
+ } catch (err) {
25
+ console.error(`Error: ${(err as Error).message}`);
26
+ process.exit(1);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,73 @@
1
+ import type { Command } from 'commander';
2
+ import { HttpClient } from '../http-client.js';
3
+ import type { WorkflowDescriptor } from '../../types.js';
4
+
5
+ export function registerWorkflowCommand(program: Command): void {
6
+ const wf = program
7
+ .command('workflow')
8
+ .description('Manage multi-step workflows');
9
+
10
+ wf.command('run')
11
+ .description('Create and run a workflow from JSON')
12
+ .argument('<json>', 'Workflow JSON: { name, steps: [{ prompt, preferredAgent? }], mode? }')
13
+ .option('--cwd <path>', 'Working directory')
14
+ .option('--priority <n>', 'Priority 1-5', '3')
15
+ .action(async (json: string, options: { cwd?: string; priority?: string }) => {
16
+ const client = new HttpClient();
17
+ try {
18
+ const body = JSON.parse(json);
19
+ body.workingDirectory = options.cwd;
20
+ body.priority = parseInt(options.priority ?? '3', 10);
21
+
22
+ const wf = await client.post<WorkflowDescriptor>('/workflows', body);
23
+ console.log(`Workflow: ${wf.workflowId} ${wf.name}`);
24
+ console.log(`Status: ${wf.status}`);
25
+ console.log(`Steps: ${wf.steps.length} (${wf.mode})`);
26
+ console.log(`Tasks: ${wf.taskIds.join(', ') || 'none'}`);
27
+ } catch (err) {
28
+ console.error(`Error: ${(err as Error).message}`);
29
+ process.exit(1);
30
+ }
31
+ });
32
+
33
+ wf.command('status')
34
+ .description('Get workflow status')
35
+ .argument('<id>', 'Workflow ID')
36
+ .action(async (id: string) => {
37
+ const client = new HttpClient();
38
+ try {
39
+ const wf = await client.get<WorkflowDescriptor>(`/workflows/${id}`);
40
+ console.log(`Workflow: ${wf.workflowId}`);
41
+ console.log(`Name: ${wf.name}`);
42
+ console.log(`Mode: ${wf.mode}`);
43
+ console.log(`Status: ${wf.status}`);
44
+ console.log(`Progress: ${wf.currentStep + 1}/${wf.steps.length}`);
45
+ console.log(`Tasks: ${wf.taskIds.join(', ') || 'none'}`);
46
+ } catch (err) {
47
+ console.error(`Error: ${(err as Error).message}`);
48
+ process.exit(1);
49
+ }
50
+ });
51
+
52
+ wf.command('list')
53
+ .description('List workflows')
54
+ .option('--limit <n>', 'Number of workflows', '20')
55
+ .action(async (options: { limit?: string }) => {
56
+ const client = new HttpClient();
57
+ try {
58
+ const wfs = await client.get<WorkflowDescriptor[]>(`/workflows?limit=${options.limit ?? '20'}`);
59
+ if (wfs.length === 0) {
60
+ console.log('No workflows found.');
61
+ return;
62
+ }
63
+ console.log('ID Status Mode Name');
64
+ console.log('─'.repeat(60));
65
+ for (const w of wfs) {
66
+ console.log(`${w.workflowId} ${w.status.padEnd(10)} ${w.mode.padEnd(11)} ${w.name}`);
67
+ }
68
+ } catch (err) {
69
+ console.error(`Error: ${(err as Error).message}`);
70
+ process.exit(1);
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,8 @@
1
+ export function handleCliError(err: unknown): never {
2
+ const message = (err as Error).message;
3
+ console.error(`Error: ${message}`);
4
+ if (message.includes('fetch failed') || message.includes('ECONNREFUSED')) {
5
+ console.error('Daemon not started. Run: agw daemon start');
6
+ }
7
+ process.exit(1);
8
+ }
@@ -0,0 +1,68 @@
1
+ const DEFAULT_BASE = 'http://127.0.0.1:4927';
2
+
3
+ export class HttpClient {
4
+ private authToken?: string;
5
+
6
+ constructor(private baseUrl: string = DEFAULT_BASE) {
7
+ this.authToken = process.env.AGW_AUTH_TOKEN;
8
+ }
9
+
10
+ private headers(extra?: Record<string, string>): Record<string, string> {
11
+ const h: Record<string, string> = { ...extra };
12
+ if (this.authToken) h['Authorization'] = `Bearer ${this.authToken}`;
13
+ return h;
14
+ }
15
+
16
+ async post<T>(path: string, body: unknown): Promise<T> {
17
+ const res = await fetch(`${this.baseUrl}${path}`, {
18
+ method: 'POST',
19
+ headers: this.headers({ 'Content-Type': 'application/json' }),
20
+ body: JSON.stringify(body),
21
+ });
22
+ if (!res.ok) {
23
+ const err = await res.json().catch(() => ({ error: res.statusText }));
24
+ throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
25
+ }
26
+ return res.json() as Promise<T>;
27
+ }
28
+
29
+ async get<T>(path: string): Promise<T> {
30
+ const res = await fetch(`${this.baseUrl}${path}`, {
31
+ headers: this.headers(),
32
+ });
33
+ if (!res.ok) {
34
+ const err = await res.json().catch(() => ({ error: res.statusText }));
35
+ throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
36
+ }
37
+ return res.json() as Promise<T>;
38
+ }
39
+
40
+ async stream(path: string, onEvent: (event: string, data: string) => void): Promise<void> {
41
+ const res = await fetch(`${this.baseUrl}${path}`, {
42
+ headers: this.headers({ Accept: 'text/event-stream' }),
43
+ });
44
+ if (!res.ok || !res.body) {
45
+ throw new Error(`Stream failed: HTTP ${res.status}`);
46
+ }
47
+ const reader = res.body.getReader();
48
+ const decoder = new TextDecoder();
49
+ let buffer = '';
50
+
51
+ while (true) {
52
+ const { done, value } = await reader.read();
53
+ if (done) break;
54
+ buffer += decoder.decode(value, { stream: true });
55
+
56
+ const parts = buffer.split('\n\n');
57
+ buffer = parts.pop() ?? '';
58
+
59
+ for (const part of parts) {
60
+ const eventMatch = part.match(/^event: (.+)$/m);
61
+ const dataMatch = part.match(/^data: (.+)$/m);
62
+ if (eventMatch && dataMatch) {
63
+ onEvent(eventMatch[1], dataMatch[1]);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander';
2
+ import { registerRunCommand } from './commands/run.js';
3
+ import { registerStatusCommand } from './commands/status.js';
4
+ import { registerHistoryCommand } from './commands/history.js';
5
+ import { registerAgentsCommand } from './commands/agents.js';
6
+ import { registerDaemonCommand } from './commands/daemon.js';
7
+ import { registerCostsCommand } from './commands/costs.js';
8
+ import { registerWorkflowCommand } from './commands/workflow.js';
9
+ import { registerComboCommand } from './commands/combo.js';
10
+
11
+ export function createCli(): Command {
12
+ const program = new Command();
13
+ program
14
+ .name('agw')
15
+ .description('Agent Gateway — route tasks to the best AI agent')
16
+ .version('0.3.0');
17
+
18
+ registerRunCommand(program);
19
+ registerStatusCommand(program);
20
+ registerHistoryCommand(program);
21
+ registerAgentsCommand(program);
22
+ registerDaemonCommand(program);
23
+ registerCostsCommand(program);
24
+ registerWorkflowCommand(program);
25
+ registerComboCommand(program);
26
+
27
+ return program;
28
+ }
package/src/config.ts ADDED
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs';
2
+ import type { AppConfig, AgentConfig } from './types.js';
3
+
4
+ const DEFAULT_AGENTS: Record<string, AgentConfig> = {
5
+ claude: { enabled: true, command: 'claude', args: [] },
6
+ codex: { enabled: true, command: 'codex', args: [] },
7
+ gemini: { enabled: true, command: 'gemini', args: [] },
8
+ };
9
+
10
+ const DEFAULTS: AppConfig = {
11
+ port: 4927,
12
+ anthropicApiKey: '',
13
+ routerModel: 'claude-haiku-4-5-20251001',
14
+ defaultTimeout: 300_000,
15
+ maxConcurrencyPerAgent: 3,
16
+ maxPromptLength: 100_000,
17
+ maxWorkflowSteps: 20,
18
+ agents: DEFAULT_AGENTS,
19
+ };
20
+
21
+ export function loadConfig(configPath: string): AppConfig {
22
+ let fileConfig: Partial<AppConfig> = {};
23
+
24
+ if (fs.existsSync(configPath)) {
25
+ try {
26
+ const raw = fs.readFileSync(configPath, 'utf-8');
27
+ fileConfig = JSON.parse(raw);
28
+ } catch {
29
+ // Ignore malformed config, use defaults
30
+ }
31
+ }
32
+
33
+ const agents: Record<string, AgentConfig> = { ...DEFAULT_AGENTS };
34
+ if (fileConfig.agents) {
35
+ for (const [id, agentConf] of Object.entries(fileConfig.agents)) {
36
+ agents[id] = { ...DEFAULT_AGENTS[id], ...agentConf };
37
+ }
38
+ }
39
+
40
+ const port = process.env.AGW_PORT
41
+ ? parseInt(process.env.AGW_PORT, 10)
42
+ : fileConfig.port ?? DEFAULTS.port;
43
+
44
+ const anthropicApiKey =
45
+ process.env.ANTHROPIC_API_KEY ??
46
+ fileConfig.anthropicApiKey ??
47
+ DEFAULTS.anthropicApiKey;
48
+
49
+ const authToken =
50
+ process.env.AGW_AUTH_TOKEN ??
51
+ fileConfig.authToken ??
52
+ undefined;
53
+
54
+ return {
55
+ port,
56
+ anthropicApiKey,
57
+ authToken,
58
+ routerModel: fileConfig.routerModel ?? DEFAULTS.routerModel,
59
+ defaultTimeout: fileConfig.defaultTimeout ?? DEFAULTS.defaultTimeout,
60
+ maxConcurrencyPerAgent: fileConfig.maxConcurrencyPerAgent ?? DEFAULTS.maxConcurrencyPerAgent,
61
+ dailyCostLimit: fileConfig.dailyCostLimit,
62
+ monthlyCostLimit: fileConfig.monthlyCostLimit,
63
+ allowedWorkspaces: fileConfig.allowedWorkspaces,
64
+ maxPromptLength: fileConfig.maxPromptLength ?? DEFAULTS.maxPromptLength,
65
+ maxWorkflowSteps: fileConfig.maxWorkflowSteps ?? DEFAULTS.maxWorkflowSteps,
66
+ agents,
67
+ };
68
+ }
@@ -0,0 +1,35 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+
4
+ function safeCompare(a: string, b: string): boolean {
5
+ if (a.length !== b.length) return false;
6
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
7
+ }
8
+
9
+ export function registerAuthMiddleware(app: FastifyInstance, authToken?: string): void {
10
+ if (!authToken) {
11
+ // No auth — restrict to loopback only
12
+ app.addHook('onRequest', async (request, reply) => {
13
+ const ip = request.ip;
14
+ const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
15
+ if (!isLoopback) {
16
+ return reply.status(403).send({
17
+ error: 'Auth token required for non-loopback access. Set AGW_AUTH_TOKEN.',
18
+ });
19
+ }
20
+ });
21
+ return;
22
+ }
23
+
24
+ const expected = `Bearer ${authToken}`;
25
+
26
+ app.addHook('onRequest', async (request, reply) => {
27
+ // Skip auth for Web UI static page (auth handled client-side via header)
28
+ if (request.url === '/ui') return;
29
+
30
+ const header = request.headers.authorization ?? '';
31
+ if (!safeCompare(header, expected)) {
32
+ return reply.status(401).send({ error: 'Unauthorized — invalid or missing token' });
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,63 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+
3
+ interface TokenBucket {
4
+ tokens: number;
5
+ lastRefill: number;
6
+ }
7
+
8
+ export interface RateLimitConfig {
9
+ maxRequests: number; // tokens per window
10
+ windowMs: number; // refill interval in ms
11
+ }
12
+
13
+ const DEFAULT_CONFIG: RateLimitConfig = { maxRequests: 60, windowMs: 60_000 };
14
+
15
+ export class RateLimiter {
16
+ private buckets = new Map<string, TokenBucket>();
17
+ private config: RateLimitConfig;
18
+
19
+ constructor(config?: Partial<RateLimitConfig>) {
20
+ this.config = { ...DEFAULT_CONFIG, ...config };
21
+ }
22
+
23
+ check(clientId: string): { allowed: boolean; remaining: number; resetMs: number } {
24
+ const now = Date.now();
25
+ let bucket = this.buckets.get(clientId);
26
+
27
+ if (!bucket) {
28
+ bucket = { tokens: this.config.maxRequests, lastRefill: now };
29
+ this.buckets.set(clientId, bucket);
30
+ }
31
+
32
+ // Refill
33
+ const elapsed = now - bucket.lastRefill;
34
+ if (elapsed >= this.config.windowMs) {
35
+ bucket.tokens = this.config.maxRequests;
36
+ bucket.lastRefill = now;
37
+ }
38
+
39
+ if (bucket.tokens > 0) {
40
+ bucket.tokens--;
41
+ return { allowed: true, remaining: bucket.tokens, resetMs: this.config.windowMs - (now - bucket.lastRefill) };
42
+ }
43
+
44
+ return { allowed: false, remaining: 0, resetMs: this.config.windowMs - (now - bucket.lastRefill) };
45
+ }
46
+ }
47
+
48
+ export function registerRateLimiter(app: FastifyInstance, config?: Partial<RateLimitConfig>): RateLimiter {
49
+ const limiter = new RateLimiter(config);
50
+
51
+ app.addHook('onRequest', async (request, reply) => {
52
+ if (request.method === 'GET') return; // Don't rate-limit reads
53
+ const clientId = request.ip;
54
+ const { allowed, remaining, resetMs } = limiter.check(clientId);
55
+ reply.header('X-RateLimit-Remaining', remaining);
56
+ reply.header('X-RateLimit-Reset', Math.ceil(resetMs / 1000));
57
+ if (!allowed) {
58
+ return reply.status(429).send({ error: 'Rate limit exceeded', retryAfterMs: resetMs });
59
+ }
60
+ });
61
+
62
+ return limiter;
63
+ }
@@ -0,0 +1,64 @@
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ export interface Tenant {
5
+ id: string;
6
+ name: string;
7
+ apiKey: string;
8
+ quotaDailyLimit?: number;
9
+ quotaMonthlyLimit?: number;
10
+ allowedAgents?: string[];
11
+ maxConcurrency?: number;
12
+ }
13
+
14
+ declare module 'fastify' {
15
+ interface FastifyRequest {
16
+ tenant?: Tenant;
17
+ }
18
+ }
19
+
20
+ export class TenantManager {
21
+ private tenants = new Map<string, Tenant>();
22
+ private keyIndex = new Map<string, string>(); // hashedKey → tenantId
23
+
24
+ addTenant(tenant: Tenant): void {
25
+ this.tenants.set(tenant.id, tenant);
26
+ const hash = createHash('sha256').update(tenant.apiKey).digest('hex');
27
+ this.keyIndex.set(hash, tenant.id);
28
+ }
29
+
30
+ removeTenant(id: string): void {
31
+ const tenant = this.tenants.get(id);
32
+ if (tenant) {
33
+ const hash = createHash('sha256').update(tenant.apiKey).digest('hex');
34
+ this.keyIndex.delete(hash);
35
+ this.tenants.delete(id);
36
+ }
37
+ }
38
+
39
+ resolveByApiKey(apiKey: string): Tenant | undefined {
40
+ const hash = createHash('sha256').update(apiKey).digest('hex');
41
+ const tenantId = this.keyIndex.get(hash);
42
+ return tenantId ? this.tenants.get(tenantId) : undefined;
43
+ }
44
+
45
+ getTenant(id: string): Tenant | undefined {
46
+ return this.tenants.get(id);
47
+ }
48
+
49
+ listTenants(): Tenant[] {
50
+ return Array.from(this.tenants.values()).map(t => ({ ...t, apiKey: '***' }));
51
+ }
52
+ }
53
+
54
+ export function registerTenantMiddleware(app: FastifyInstance, tenantManager: TenantManager): void {
55
+ if (tenantManager.listTenants().length === 0) return;
56
+
57
+ app.addHook('onRequest', async (request: FastifyRequest) => {
58
+ const header = request.headers.authorization;
59
+ if (!header?.startsWith('Bearer ')) return;
60
+ const key = header.slice(7);
61
+ const tenant = tenantManager.resolveByApiKey(key);
62
+ if (tenant) request.tenant = tenant;
63
+ });
64
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function validateWorkspace(workingDirectory: string | undefined, allowedWorkspaces?: string[]): string {
5
+ const resolved = workingDirectory ?? process.cwd();
6
+
7
+ // Resolve to real path (follows symlinks, canonicalizes)
8
+ let realDir: string;
9
+ try {
10
+ realDir = fs.realpathSync(resolved);
11
+ } catch {
12
+ throw new Error(`Working directory does not exist: ${resolved}`);
13
+ }
14
+
15
+ // Verify it's a directory
16
+ if (!fs.statSync(realDir).isDirectory()) {
17
+ throw new Error(`Not a directory: ${resolved}`);
18
+ }
19
+
20
+ // If no allowedWorkspaces configured, allow any local directory
21
+ if (!allowedWorkspaces || allowedWorkspaces.length === 0) return realDir;
22
+
23
+ // Check if realDir is under any allowed workspace
24
+ for (const allowed of allowedWorkspaces) {
25
+ let realAllowed: string;
26
+ try {
27
+ realAllowed = fs.realpathSync(allowed);
28
+ } catch {
29
+ continue;
30
+ }
31
+ if (realDir === realAllowed || realDir.startsWith(realAllowed + path.sep)) {
32
+ return realDir;
33
+ }
34
+ }
35
+
36
+ throw new Error(
37
+ `Working directory ${resolved} is outside allowed workspaces. ` +
38
+ `Allowed: ${allowedWorkspaces.join(', ')}`
39
+ );
40
+ }