diff --git a/e2e/couch-sync.test.ts b/e2e/couch-sync.test.ts index d93292c..9dcc2d1 100644 --- a/e2e/couch-sync.test.ts +++ b/e2e/couch-sync.test.ts @@ -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) && diff --git a/src/daemon-entry.ts b/src/daemon-entry.ts index a2597db..ec0a912 100644 --- a/src/daemon-entry.ts +++ b/src/daemon-entry.ts @@ -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'; @@ -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 => { const port = resolvePort(); + const authToken = generateDaemonToken(); const git = createSyncManager(); const couch = createCouchSyncManager(); const discover = createDiscoverWatcher({ @@ -53,11 +57,13 @@ const main = async (): Promise => { 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(); @@ -77,6 +83,7 @@ const main = async (): Promise => { server.stop().finally(() => { removePidFile(); removePortFile(); + removeTokenFile(); process.exit(0); }); }; @@ -88,5 +95,6 @@ main().catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); removePidFile(); removePortFile(); + removeTokenFile(); process.exit(1); }); diff --git a/src/daemon/guards.ts b/src/daemon/guards.ts new file mode 100644 index 0000000..cac23a1 --- /dev/null +++ b/src/daemon/guards.ts @@ -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); diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index a122703..d6c3df3 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -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; @@ -53,6 +71,7 @@ export const isDaemonRunning = (): boolean => { if (!isProcessAlive(pid)) { removePidFile(); removePortFile(); + removeTokenFile(); return false; } return true; @@ -67,6 +86,7 @@ export const stopDaemon = (): boolean => { if (alive) process.kill(pid, 'SIGTERM'); removePidFile(); removePortFile(); + removeTokenFile(); return alive; }; diff --git a/src/daemon/mcp-http.test.ts b/src/daemon/mcp-http.test.ts index 497e4e6..5af5cc8 100644 --- a/src/daemon/mcp-http.test.ts +++ b/src/daemon/mcp-http.test.ts @@ -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); @@ -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); diff --git a/src/daemon/mcp-http.ts b/src/daemon/mcp-http.ts index 3e80ea2..7362a8f 100644 --- a/src/daemon/mcp-http.ts +++ b/src/daemon/mcp-http.ts @@ -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 + buildServer: () => Promise, + allowedHosts: string[] ): Promise => { if (req.method !== 'POST') { jsonRpcError(res, 405, 'Method not allowed: this endpoint is stateless (POST only).'); @@ -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(); diff --git a/src/daemon/server-security.test.ts b/src/daemon/server-security.test.ts new file mode 100644 index 0000000..f4d4c4c --- /dev/null +++ b/src/daemon/server-security.test.ts @@ -0,0 +1,219 @@ +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRegistry, type VaultsConfig } from '@agentage/memory-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createLocalMemoryServer } from '../mcp/local-server.js'; +import { isAllowedHost, isAllowedOrigin } from './guards.js'; +import { createDaemonServer, type DaemonServer, type DaemonServerOptions } from './server.js'; +import { type MemoryClient } from '../lib/memory-client.js'; + +const TOKEN = 'server-security-token'; + +const mockClient = (): MemoryClient => ({ + search: vi.fn(async () => ({ results: [] })), + read: vi.fn(async () => ({ + path: 'a.md', + title: 'A', + frontmatter: {}, + body: 'hi', + tags: [], + updated: 'now', + deleted: false, + })), + write: vi.fn(async () => ({ path: 'a.md', rev: 'r', updated: 'now' })), + edit: vi.fn(async () => ({ path: 'a.md', rev: 'r', updated: 'now' })), + list: vi.fn(async () => ({ folder: '', entries: [], truncated: false, files: 0 })), + delete: vi.fn(async () => ({ path: 'a.md', deleted: true })), +}); + +const mcpConfig: VaultsConfig = { + version: 1, + default: 'main', + vaults: { main: { path: join(tmpdir(), 'agentage-security-test'), mcp: ['local'] } }, +}; + +const start = async ( + over: Partial = {} +): Promise<{ + port: number; + srv: DaemonServer; +}> => { + const srv = createDaemonServer({ + getClient: () => mockClient(), + buildMcpServer: async () => createLocalMemoryServer(await createRegistry(mcpConfig)), + authToken: TOKEN, + version: '9.9.9', + ...over, + }); + await srv.start(0); + const addr = srv.server.address(); + return { port: typeof addr === 'object' && addr ? addr.port : 0, srv }; +}; + +interface RawResponse { + status: number; + body: string; +} + +const raw = ( + port: number, + opts: { method?: string; path: string; headers?: Record; body?: string } +): Promise => + new Promise((resolve, reject) => { + const req = httpRequest( + { + host: '127.0.0.1', + port, + method: opts.method ?? 'GET', + path: opts.path, + headers: opts.headers, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }) + ); + } + ); + req.on('error', reject); + if (opts.body !== undefined) req.write(opts.body); + req.end(); + }); + +const jsonAuth = { 'content-type': 'application/json', 'x-agentage-token': TOKEN }; + +let running: DaemonServer | undefined; +afterEach(async () => { + await running?.stop(); + running = undefined; + vi.restoreAllMocks(); +}); + +describe('loopback origin guards (pure)', () => { + it('allows only the three loopback hosts at the bound port', () => { + expect(isAllowedHost('127.0.0.1:4243', 4243)).toBe(true); + expect(isAllowedHost('localhost:4243', 4243)).toBe(true); + expect(isAllowedHost('[::1]:4243', 4243)).toBe(true); + expect(isAllowedHost('127.0.0.1:9999', 4243)).toBe(false); + expect(isAllowedHost('evil.example', 4243)).toBe(false); + expect(isAllowedHost(undefined, 4243)).toBe(false); + }); + + it('allows an absent or loopback Origin and rejects a web Origin', () => { + expect(isAllowedOrigin(undefined)).toBe(true); + expect(isAllowedOrigin('http://127.0.0.1:4243')).toBe(true); + expect(isAllowedOrigin('http://localhost')).toBe(true); + expect(isAllowedOrigin('https://evil.example')).toBe(false); + expect(isAllowedOrigin('http://attacker.localhost.evil.com')).toBe(false); + }); +}); + +describe('daemon HTTP web-origin defense', () => { + it('403s a mismatched Host header (DNS rebinding)', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { path: '/api/health', headers: { host: 'evil.example' } }); + expect(res.status).toBe(403); + }); + + it('403s a non-loopback Origin header', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { + path: '/api/health', + headers: { origin: 'https://evil.example' }, + }); + expect(res.status).toBe(403); + }); + + it('keeps /api/health tokenless under a valid Host', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { path: '/api/health' }); + expect(res.status).toBe(200); + expect(JSON.parse(res.body).ok).toBe(true); + }); + + it('401s an /api/memory call without the token', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { + method: 'POST', + path: '/api/memory/read', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ref: 'a.md' }), + }); + expect(res.status).toBe(401); + }); + + it('401s an /api/memory call with the wrong token', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { + method: 'POST', + path: '/api/memory/read', + headers: { 'content-type': 'application/json', 'x-agentage-token': 'nope' }, + body: JSON.stringify({ ref: 'a.md' }), + }); + expect(res.status).toBe(401); + }); + + it('415s a POST /api/* that is not application/json (browser simple request)', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { + method: 'POST', + path: '/api/memory/write', + headers: { 'content-type': 'text/plain', 'x-agentage-token': TOKEN }, + body: JSON.stringify({ ref: 'a.md', body: 'x' }), + }); + expect(res.status).toBe(415); + }); + + it('serves an authenticated JSON /api/memory call', async () => { + const { port, srv } = await start(); + running = srv; + const res = await raw(port, { + method: 'POST', + path: '/api/memory/read', + headers: jsonAuth, + body: JSON.stringify({ ref: 'a.md' }), + }); + expect(res.status).toBe(200); + expect(JSON.parse(res.body).path).toBe('a.md'); + }); + + it('leaves /mcp tokenless but Host-validated', async () => { + const { port, srv } = await start(); + running = srv; + const init = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 't', version: '0' }, + }, + }); + const ok = await raw(port, { + method: 'POST', + path: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: init, + }); + expect(ok.status).toBe(200); + const badHost = await raw(port, { + method: 'POST', + path: '/mcp', + headers: { host: 'evil.example', 'content-type': 'application/json' }, + body: init, + }); + expect(badHost.status).toBe(403); + }); +}); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index b97e21a..75e5809 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -5,9 +5,11 @@ import { type SyncResult } from '../sync/cycle.js'; import { type CouchSyncResult } from '../sync/couch/manager.js'; import { type SyncStatus } from '../sync/manager.js'; import { dispatchMemory, isMemoryVerb, type MemoryVerb } from './actions.js'; +import { isAllowedHost, isAllowedOrigin, loopbackHosts } from './guards.js'; import { handleMcp } from './mcp-http.js'; const LOOPBACK = '127.0.0.1'; +const AUTH_HEADER = 'x-agentage-token'; export interface DaemonSyncApi { status: () => SyncStatus; @@ -24,6 +26,8 @@ export interface DaemonServerOptions { // Fired after a successful write/edit/delete so the account channel can push-on-save. Never // awaited: a couch failure must not affect the memory API response. onMutation?: (verb: MemoryVerb, body: unknown) => void; + // Per-daemon secret required on X-Agentage-Token for every /api/* call except /api/health. + authToken: string; version: string; startedAt?: number; } @@ -55,15 +59,38 @@ const send = (res: ServerResponse, status: number, body: unknown): void => { res.end(JSON.stringify(body)); }; +const isJsonContentType = (req: IncomingMessage): boolean => + (req.headers['content-type'] ?? '').split(';')[0]?.trim() === 'application/json'; + +const header = (req: IncomingMessage, name: string): string | undefined => { + const v = req.headers[name]; + return Array.isArray(v) ? v[0] : v; +}; + // Loopback-only JSON HTTP: GET /api/health + POST /api/memory/ (the CLI verbs) + POST /mcp -// (the frozen 6 tools for on-machine AI clients, stateless Streamable HTTP). No auth (local socket -// trust); the verbs dispatch to one shared MemoryClient, /mcp builds a fresh server per request. +// (the frozen 6 tools for on-machine AI clients, stateless Streamable HTTP). Web-origin defense on +// EVERY request (Host + Origin allow-lists + DNS-rebinding protection on /mcp); /api/* additionally +// needs the per-daemon token, while /health and /mcp stay tokenless for probes and editor clients. export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { const startedAt = opts.startedAt ?? Date.now(); let served = 0; + let boundPort = 0; + + // 403 unless the request looks like it came from a genuine same-machine loopback client. + const originGuard = (req: IncomingMessage): boolean => + isAllowedHost(header(req, 'host'), boundPort) && isAllowedOrigin(header(req, 'origin')); const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { const url = req.url ?? '/'; + if (!originGuard(req)) return send(res, 403, { error: 'forbidden' }); + const path = url.split('?')[0] ?? url; + const isApi = path.startsWith('/api/') && path !== '/api/health'; + if (isApi && header(req, AUTH_HEADER) !== opts.authToken) { + return send(res, 401, { error: 'unauthorized' }); + } + if (isApi && req.method === 'POST' && !isJsonContentType(req)) { + return send(res, 415, { error: 'unsupported media type' }); + } if (req.method === 'GET' && url === '/api/health') { return send(res, 200, { ok: true, @@ -73,9 +100,9 @@ export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { served, }); } - if ((url.split('?')[0] ?? url) === '/mcp') { + if (path === '/mcp') { if (!opts.buildMcpServer) return send(res, 404, { error: 'not found' }); - return handleMcp(req, res, opts.buildMcpServer); + return handleMcp(req, res, opts.buildMcpServer, loopbackHosts(boundPort)); } if (req.method === 'GET' && url === '/api/sync/status') { if (!opts.sync) return send(res, 404, { error: 'not found' }); @@ -120,6 +147,8 @@ export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { reject(err.code === 'EADDRINUSE' ? new Error(`port ${port} already in use`) : err); server.once('error', onError); server.listen(port, host, () => { + const addr = server.address(); + boundPort = typeof addr === 'object' && addr ? addr.port : port; server.removeListener('error', onError); resolve(); }); diff --git a/src/lib/daemon-client.test.ts b/src/lib/daemon-client.test.ts index 15992fe..c2ae28c 100644 --- a/src/lib/daemon-client.test.ts +++ b/src/lib/daemon-client.test.ts @@ -1,7 +1,13 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { createDaemonServer } from '../daemon/server.js'; +import { writeTokenFile } from '../daemon/lifecycle.js'; import { VERSION } from '../utils/version.js'; import { type MemoryClient } from './memory-client.js'; + +const TOKEN = 'test-daemon-token'; import { createDaemonClient, ensureDaemon, @@ -33,13 +39,32 @@ const startServer = async ( getClient: () => MemoryClient, version = '9.9.9' ): Promise<{ port: number; stop: () => Promise }> => { - const srv = createDaemonServer({ getClient, version, startedAt: Date.now() - 3000 }); + const srv = createDaemonServer({ + getClient, + authToken: TOKEN, + version, + startedAt: Date.now() - 3000, + }); await srv.start(0); const addr = srv.server.address(); const port = typeof addr === 'object' && addr ? addr.port : 0; return { port, stop: () => srv.stop() }; }; +// A shared config dir holds the token file the DaemonClient reads to authenticate its calls. +let configDir: string; +const savedConfigDir = process.env['AGENTAGE_CONFIG_DIR']; +beforeAll(() => { + configDir = mkdtempSync(join(tmpdir(), 'cli-daemon-client-')); + process.env['AGENTAGE_CONFIG_DIR'] = configDir; + writeTokenFile(TOKEN); +}); +afterAll(() => { + rmSync(configDir, { recursive: true, force: true }); + if (savedConfigDir === undefined) delete process.env['AGENTAGE_CONFIG_DIR']; + else process.env['AGENTAGE_CONFIG_DIR'] = savedConfigDir; +}); + afterEach(() => vi.restoreAllMocks()); describe('DaemonClient <-> server round trip', () => { @@ -82,15 +107,17 @@ describe('DaemonClient <-> server round trip', () => { it('404s an unknown verb and unknown route, 400s a bad JSON body', async () => { const { port, stop } = await startServer(() => mockClient()); try { + const auth = { 'Content-Type': 'application/json', 'X-Agentage-Token': TOKEN }; const bogus = await fetch(`http://127.0.0.1:${port}/api/memory/bogus`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: auth, body: '{}', }); expect(bogus.status).toBe(404); expect((await fetch(`http://127.0.0.1:${port}/nope`)).status).toBe(404); const bad = await fetch(`http://127.0.0.1:${port}/api/memory/read`, { method: 'POST', + headers: auth, body: '{oops', }); expect(bad.status).toBe(400); diff --git a/src/lib/daemon-client.ts b/src/lib/daemon-client.ts index 5b66245..c12e721 100644 --- a/src/lib/daemon-client.ts +++ b/src/lib/daemon-client.ts @@ -8,7 +8,7 @@ import { type SearchResult, type WriteResult, } from '@agentage/memory-core'; -import { resolvePort } from '../daemon/lifecycle.js'; +import { readDaemonToken, resolvePort } from '../daemon/lifecycle.js'; import { type SyncResult } from '../sync/cycle.js'; import { type CouchSyncResult } from '../sync/couch/manager.js'; import { type SyncStatus } from '../sync/manager.js'; @@ -34,10 +34,17 @@ export interface Health { const base = (port: number): string => `http://127.0.0.1:${port}`; +// The 0600 token file is the daemon's auth: read it fresh per call so a restart's new token is +// picked up; absent means we cannot authenticate, so /api/* will 401 and callers fall back. +const apiHeaders = (): Record => { + const token = readDaemonToken(); + return { 'Content-Type': 'application/json', ...(token ? { 'X-Agentage-Token': token } : {}) }; +}; + const post = async (port: number, verb: string, body: unknown): Promise => { const res = await fetch(`${base(port)}/api/memory/${verb}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: apiHeaders(), body: JSON.stringify(body), }); if (!res.ok) { @@ -63,6 +70,7 @@ export const health = async (port: number, timeoutMs = 1000): Promise => { try { const res = await fetch(`${base(port)}/api/sync/status`, { + headers: apiHeaders(), signal: AbortSignal.timeout(timeoutMs), }); return res.ok ? ((await res.json()) as SyncStatus) : null; @@ -76,7 +84,7 @@ export const syncStatus = async (port: number, timeoutMs = 1000): Promise => { const res = await fetch(`${base(port)}/api/sync/run`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: apiHeaders(), body: JSON.stringify({ vault }), }); if (!res.ok) { @@ -149,20 +157,24 @@ export interface EnsureDeps { port?: number; probe?: (port: number) => Promise; spawn?: (port: number) => Promise; + readToken?: () => string | null; } // DO3/DO4/DO9: prefer a live daemon, autostart one if absent, and return null when it is -// unreachable or cannot be forked so the caller falls back to the in-process DirectClient. +// unreachable, cannot be forked, or has no readable token (nothing to authenticate with) so the +// caller falls back to the in-process DirectClient. export const ensureDaemon = async (deps: EnsureDeps = {}): Promise => { const port = deps.port ?? resolvePort(); const probe = deps.probe ?? health; const spawn = deps.spawn ?? spawnDaemon; + const readToken = deps.readToken ?? readDaemonToken; const existing = await probe(port); if (existing) { + if (!readToken()) return null; const notice = mismatchNotice(existing.version); if (notice) console.error(chalk.yellow(notice)); return createDaemonClient(port); } const started = await spawn(port).catch(() => false); - return started ? createDaemonClient(port) : null; + return started && readToken() ? createDaemonClient(port) : null; };