Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions e2e/couch-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,10 +587,14 @@ test.describe('couch account sync (hermetic) @couch', () => {
// The watcher provisioned the discovered vault's cloud channel.
await waitFor(async () => stub.provisioned().includes('teamnotes'));

// /api/sync/status lists both the discover root and the new couch target.
// /api/sync/status lists both the discover root and the new couch target. /api/* now needs
// the daemon's per-boot token (0600 in the config dir).
const daemonToken = readFileSync(join(m.configDir, 'daemon.token'), 'utf-8').trim();
await waitFor(async () => {
const s = (await (
await fetch(`http://127.0.0.1:${daemonPort}/api/sync/status`)
await fetch(`http://127.0.0.1:${daemonPort}/api/sync/status`, {
headers: { 'X-Agentage-Token': daemonToken },
})
).json()) as { couch?: { vault: string }[]; discover?: { roots: string[] } };
return (
(s.discover?.roots ?? []).includes(rootDir) &&
Expand Down
8 changes: 8 additions & 0 deletions src/daemon-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { unwatchFile, watchFile } from 'node:fs';
import { isAccountVault } from '@agentage/memory-core';
import { createClientProvider } from './daemon/client-provider.js';
import {
generateDaemonToken,
removePidFile,
removePortFile,
removeTokenFile,
resolvePort,
writePidFile,
writePortFile,
writeTokenFile,
} from './daemon/lifecycle.js';
import { createDaemonServer } from './daemon/server.js';
import { loadLocalMemoryServer } from './mcp/local-server.js';
Expand All @@ -29,6 +32,7 @@ const envInt = (name: string): number | undefined => {
// runs both sync loops (git origins + the account/couch channel) and reschedules on config change.
const main = async (): Promise<void> => {
const port = resolvePort();
const authToken = generateDaemonToken();
const git = createSyncManager();
const couch = createCouchSyncManager();
const discover = createDiscoverWatcher({
Expand All @@ -53,11 +57,13 @@ const main = async (): Promise<void> => {
runNow,
},
onMutation: (verb, body) => couch.onWrite(verb, body),
authToken,
version: VERSION,
});
await server.start(port);
writePidFile(process.pid);
writePortFile(port);
writeTokenFile(authToken);
git.reschedule();
couch.reschedule();
discover.reschedule();
Expand All @@ -77,6 +83,7 @@ const main = async (): Promise<void> => {
server.stop().finally(() => {
removePidFile();
removePortFile();
removeTokenFile();
process.exit(0);
});
};
Expand All @@ -88,5 +95,6 @@ main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
removePidFile();
removePortFile();
removeTokenFile();
process.exit(1);
});
19 changes: 19 additions & 0 deletions src/daemon/guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Web-origin defense for the loopback daemon: a Host allow-list blocks DNS-rebinding (an attacker
// domain that later resolves to 127.0.0.1 still sends its own Host), and an Origin allow-list blocks
// a browser page from POSTing to the daemon. Both are pure so they unit-test without a socket.

// The only Host values a genuine loopback client sends, pinned to the actually-bound port.
export const loopbackHosts = (port: number): string[] => [
`127.0.0.1:${port}`,
`localhost:${port}`,
`[::1]:${port}`,
];

export const isAllowedHost = (host: string | undefined, port: number): boolean =>
host !== undefined && loopbackHosts(port).includes(host);

// A missing Origin is fine (non-browser clients omit it); a present one must be loopback (any port).
const LOOPBACK_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;

export const isAllowedOrigin = (origin: string | undefined): boolean =>
origin === undefined || LOOPBACK_ORIGIN.test(origin);
22 changes: 21 additions & 1 deletion src/daemon/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getConfigDir } from '../lib/config.js';

// The daemon's on-disk state lives beside the config so an isolated AGENTAGE_CONFIG_DIR fully
// isolates a daemon (pid, port) from any other - tests never collide with a real one.
// isolates a daemon (pid, port, token) from any other - tests never collide with a real one.
export const DEFAULT_DAEMON_PORT = 4243;

const pidPath = (): string => join(getConfigDir(), 'daemon.pid');
const portPath = (): string => join(getConfigDir(), 'daemon.port');
const tokenPath = (): string => join(getConfigDir(), 'daemon.token');

export const writePidFile = (pid: number): void => writeFileSync(pidPath(), String(pid), 'utf-8');
export const writePortFile = (port: number): void =>
writeFileSync(portPath(), String(port), 'utf-8');

// A 256-bit per-daemon secret gating /api/*; only a same-machine client that can read the 0600
// file (so, the same user) can call it, closing the loopback socket to any other local process.
export const generateDaemonToken = (): string => randomBytes(32).toString('hex');

export const writeTokenFile = (token: string): void =>
writeFileSync(tokenPath(), token, { encoding: 'utf-8', mode: 0o600 });

export const readDaemonToken = (): string | null => {
if (!existsSync(tokenPath())) return null;
const token = readFileSync(tokenPath(), 'utf-8').trim();
return token.length > 0 ? token : null;
};

export const removePidFile = (): void => {
if (existsSync(pidPath())) unlinkSync(pidPath());
};
export const removePortFile = (): void => {
if (existsSync(portPath())) unlinkSync(portPath());
};
export const removeTokenFile = (): void => {
if (existsSync(tokenPath())) unlinkSync(tokenPath());
};

const readNumberFile = (path: string): number | null => {
if (!existsSync(path)) return null;
Expand Down Expand Up @@ -53,6 +71,7 @@ export const isDaemonRunning = (): boolean => {
if (!isProcessAlive(pid)) {
removePidFile();
removePortFile();
removeTokenFile();
return false;
}
return true;
Expand All @@ -67,6 +86,7 @@ export const stopDaemon = (): boolean => {
if (alive) process.kill(pid, 'SIGTERM');
removePidFile();
removePortFile();
removeTokenFile();
return alive;
};

Expand Down
2 changes: 2 additions & 0 deletions src/daemon/mcp-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const start = async (): Promise<{ port: number; srv: DaemonServer }> => {
throw new Error('memory verbs not used in this test');
},
buildMcpServer: async () => createLocalMemoryServer(await createRegistry(config)),
authToken: 'test-token',
version: '9.9.9',
});
await srv.start(0);
Expand Down Expand Up @@ -89,6 +90,7 @@ describe('daemon POST /mcp (stateless Streamable HTTP)', () => {
getClient: () => {
throw new Error('unused');
},
authToken: 'test-token',
version: '9.9.9',
});
await srv.start(0);
Expand Down
8 changes: 6 additions & 2 deletions src/daemon/mcp-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ const jsonRpcError = (res: ServerResponse, status: number, message: string): voi

// Stateless Streamable HTTP (JSON, no session), mirroring the cloud memory endpoint: a fresh MCP
// server + transport per POST, torn down when the response closes. GET/DELETE are 405 - stateless
// exposes no server-initiated SSE stream or session teardown. Loopback trust: no auth on the socket.
// exposes no server-initiated SSE stream or session teardown. Tokenless (editor clients stay
// simple) but DNS-rebinding-protected: allowedHosts pins the Host to the bound loopback port.
export const handleMcp = async (
req: IncomingMessage,
res: ServerResponse,
buildServer: () => Promise<McpServer>
buildServer: () => Promise<McpServer>,
allowedHosts: string[]
): Promise<void> => {
if (req.method !== 'POST') {
jsonRpcError(res, 405, 'Method not allowed: this endpoint is stateless (POST only).');
Expand All @@ -23,6 +25,8 @@ export const handleMcp = async (
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
enableDnsRebindingProtection: true,
allowedHosts,
});
res.on('close', () => {
void transport.close();
Expand Down
Loading