@agentage/cli 0.0.3 → 0.25.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.
- package/CHANGELOG.md +68 -0
- package/README.md +79 -23
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +44 -4
- package/dist/commands/{setup.d.ts → auth/setup.d.ts} +6 -3
- package/dist/commands/{setup.js → auth/setup.js} +56 -11
- package/dist/commands/daemon/daemon-cmd.d.ts +2 -0
- package/dist/commands/daemon/daemon-cmd.js +124 -0
- package/dist/commands/memory/memory-verbs.d.ts +21 -0
- package/dist/commands/memory/memory-verbs.js +98 -0
- package/dist/commands/memory/memory.d.ts +2 -0
- package/dist/commands/memory/memory.js +54 -0
- package/dist/commands/{status.d.ts → status/status.d.ts} +2 -2
- package/dist/commands/status/status.js +88 -0
- package/dist/commands/update/update.d.ts +23 -0
- package/dist/commands/update/update.js +89 -0
- package/dist/commands/vault/vault-sync.d.ts +15 -0
- package/dist/commands/vault/vault-sync.js +100 -0
- package/dist/commands/vault/vault.d.ts +22 -0
- package/dist/commands/vault/vault.js +128 -0
- package/dist/daemon/actions.d.ts +5 -0
- package/dist/daemon/actions.js +22 -0
- package/dist/daemon/client-provider.d.ts +2 -0
- package/dist/daemon/client-provider.js +16 -0
- package/dist/daemon/guards.d.ts +3 -0
- package/dist/daemon/guards.js +13 -0
- package/dist/daemon/lifecycle.d.ts +17 -0
- package/dist/daemon/lifecycle.js +151 -0
- package/dist/daemon/mcp-http.d.ts +3 -0
- package/dist/daemon/mcp-http.js +34 -0
- package/dist/daemon/server.d.ts +26 -0
- package/dist/daemon/server.js +136 -0
- package/dist/daemon-entry.d.ts +7 -0
- package/dist/daemon-entry.js +128 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/lib/auth/api.d.ts +8 -0
- package/dist/lib/auth/api.js +99 -0
- package/dist/lib/auth/auth-errors.d.ts +7 -0
- package/dist/lib/auth/auth-errors.js +22 -0
- package/dist/lib/{callback-server.d.ts → auth/callback-server.d.ts} +0 -1
- package/dist/lib/{callback-server.js → auth/callback-server.js} +2 -2
- package/dist/lib/auth/credentials.d.ts +12 -0
- package/dist/lib/auth/credentials.js +50 -0
- package/dist/lib/auth/env-match.d.ts +9 -0
- package/dist/lib/auth/env-match.js +17 -0
- package/dist/lib/auth/introspect.d.ts +7 -0
- package/dist/lib/auth/introspect.js +89 -0
- package/dist/lib/{oauth.d.ts → auth/oauth.d.ts} +6 -2
- package/dist/lib/{oauth.js → auth/oauth.js} +26 -7
- package/dist/lib/auth/provision.d.ts +14 -0
- package/dist/lib/auth/provision.js +71 -0
- package/dist/lib/daemon/daemon-client.d.ts +39 -0
- package/dist/lib/daemon/daemon-client.js +134 -0
- package/dist/lib/daemon/daemon-pref.d.ts +2 -0
- package/dist/lib/daemon/daemon-pref.js +10 -0
- package/dist/lib/{config.d.ts → fs/config.d.ts} +2 -1
- package/dist/lib/fs/config.js +55 -0
- package/dist/lib/fs/file-lock.d.ts +3 -0
- package/dist/lib/fs/file-lock.js +163 -0
- package/dist/lib/memory/memory-client.d.ts +30 -0
- package/dist/lib/memory/memory-client.js +98 -0
- package/dist/lib/net/http.d.ts +6 -0
- package/dist/lib/net/http.js +32 -0
- package/dist/lib/{origins.d.ts → net/origins.d.ts} +1 -1
- package/dist/lib/net/origins.js +39 -0
- package/dist/lib/net/user-agent.d.ts +6 -0
- package/dist/lib/net/user-agent.js +7 -0
- package/dist/lib/status/format.d.ts +1 -0
- package/dist/lib/status/format.js +12 -0
- package/dist/lib/status/status-info.d.ts +46 -0
- package/dist/lib/status/status-info.js +109 -0
- package/dist/lib/status/vaults-format.d.ts +2 -0
- package/dist/lib/status/vaults-format.js +44 -0
- package/dist/lib/status/vaults-status.d.ts +12 -0
- package/dist/lib/status/vaults-status.js +53 -0
- package/dist/lib/update/update-cache.d.ts +14 -0
- package/dist/lib/update/update-cache.js +63 -0
- package/dist/lib/update/update-check.d.ts +27 -0
- package/dist/lib/update/update-check.js +50 -0
- package/dist/lib/update/update-lock.d.ts +2 -0
- package/dist/lib/update/update-lock.js +109 -0
- package/dist/lib/vault/vault-registry.d.ts +12 -0
- package/dist/lib/vault/vault-registry.js +82 -0
- package/dist/lib/vault/vaults.d.ts +10 -0
- package/dist/lib/vault/vaults.js +66 -0
- package/dist/lib/vault/vaults.schema.d.ts +2 -0
- package/dist/lib/vault/vaults.schema.js +10 -0
- package/dist/mcp/local-server.d.ts +4 -0
- package/dist/mcp/local-server.js +26 -0
- package/dist/sync/couch/cycle.d.ts +2 -0
- package/dist/sync/couch/cycle.js +58 -0
- package/dist/sync/couch/discovery.d.ts +22 -0
- package/dist/sync/couch/discovery.js +34 -0
- package/dist/sync/couch/file-store.d.ts +2 -0
- package/dist/sync/couch/file-store.js +47 -0
- package/dist/sync/couch/local-commit.d.ts +2 -0
- package/dist/sync/couch/local-commit.js +24 -0
- package/dist/sync/couch/manager.d.ts +4 -0
- package/dist/sync/couch/manager.fixtures.d.ts +20 -0
- package/dist/sync/couch/manager.fixtures.js +56 -0
- package/dist/sync/couch/manager.js +126 -0
- package/dist/sync/couch/manager.types.d.ts +81 -0
- package/dist/sync/couch/manager.types.js +1 -0
- package/dist/sync/couch/mutation-target.d.ts +5 -0
- package/dist/sync/couch/mutation-target.js +33 -0
- package/dist/sync/couch/push-on-write.d.ts +3 -0
- package/dist/sync/couch/push-on-write.js +33 -0
- package/dist/sync/couch/state-store.d.ts +3 -0
- package/dist/sync/couch/state-store.js +28 -0
- package/dist/sync/couch/targets.d.ts +9 -0
- package/dist/sync/couch/targets.js +23 -0
- package/dist/sync/couch/wire.d.ts +6 -0
- package/dist/sync/couch/wire.js +16 -0
- package/dist/sync/discover/watcher.d.ts +26 -0
- package/dist/sync/discover/watcher.js +159 -0
- package/dist/sync/git/conflict.d.ts +1 -0
- package/dist/sync/git/conflict.js +16 -0
- package/dist/sync/git/cycle.d.ts +18 -0
- package/dist/sync/git/cycle.js +158 -0
- package/dist/sync/git/git-exec.d.ts +22 -0
- package/dist/sync/git/git-exec.js +56 -0
- package/dist/sync/git/manager.d.ts +30 -0
- package/dist/sync/git/manager.js +114 -0
- package/dist/sync/git/planner.d.ts +15 -0
- package/dist/sync/git/planner.js +46 -0
- package/dist/sync/git/remote-url.d.ts +3 -0
- package/dist/sync/git/remote-url.js +30 -0
- package/dist/utils/version.d.ts +0 -1
- package/dist/utils/version.js +0 -1
- package/package.json +20 -7
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/commands/setup.d.ts.map +0 -1
- package/dist/commands/setup.js.map +0 -1
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -36
- package/dist/commands/status.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lib/api.d.ts +0 -12
- package/dist/lib/api.d.ts.map +0 -1
- package/dist/lib/api.js +0 -46
- package/dist/lib/api.js.map +0 -1
- package/dist/lib/callback-server.d.ts.map +0 -1
- package/dist/lib/callback-server.js.map +0 -1
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -34
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/oauth.d.ts.map +0 -1
- package/dist/lib/oauth.js.map +0 -1
- package/dist/lib/origins.d.ts.map +0 -1
- package/dist/lib/origins.js +0 -26
- package/dist/lib/origins.js.map +0 -1
- package/dist/lib/status-info.d.ts +0 -18
- package/dist/lib/status-info.d.ts.map +0 -1
- package/dist/lib/status-info.js +0 -39
- package/dist/lib/status-info.js.map +0 -1
- package/dist/utils/version.d.ts.map +0 -1
- package/dist/utils/version.js.map +0 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { getConfigDir } from '../lib/fs/config.js';
|
|
6
|
+
// The daemon's on-disk state lives beside the config so an isolated AGENTAGE_CONFIG_DIR fully
|
|
7
|
+
// isolates a daemon (pid, port, token) from any other - tests never collide with a real one.
|
|
8
|
+
export const DEFAULT_DAEMON_PORT = 4243;
|
|
9
|
+
// The daemon entry exits with this code on EADDRINUSE so a spawning CLI can short-circuit fast.
|
|
10
|
+
export const EADDRINUSE_EXIT_CODE = 3;
|
|
11
|
+
const pidPath = () => join(getConfigDir(), 'daemon.pid');
|
|
12
|
+
const portPath = () => join(getConfigDir(), 'daemon.port');
|
|
13
|
+
const tokenPath = () => join(getConfigDir(), 'daemon.token');
|
|
14
|
+
export const writePidFile = (pid) => writeFileSync(pidPath(), String(pid), 'utf-8');
|
|
15
|
+
export const writePortFile = (port) => writeFileSync(portPath(), String(port), 'utf-8');
|
|
16
|
+
// A 256-bit per-daemon secret gating /api/*; only a same-machine client that can read the 0600
|
|
17
|
+
// file (so, the same user) can call it, closing the loopback socket to any other local process.
|
|
18
|
+
export const generateDaemonToken = () => randomBytes(32).toString('hex');
|
|
19
|
+
export const writeTokenFile = (token) => writeFileSync(tokenPath(), token, { encoding: 'utf-8', mode: 0o600 });
|
|
20
|
+
export const readDaemonToken = () => {
|
|
21
|
+
if (!existsSync(tokenPath()))
|
|
22
|
+
return null;
|
|
23
|
+
const token = readFileSync(tokenPath(), 'utf-8').trim();
|
|
24
|
+
return token.length > 0 ? token : null;
|
|
25
|
+
};
|
|
26
|
+
export const removePidFile = () => {
|
|
27
|
+
if (existsSync(pidPath()))
|
|
28
|
+
unlinkSync(pidPath());
|
|
29
|
+
};
|
|
30
|
+
export const removePortFile = () => {
|
|
31
|
+
if (existsSync(portPath()))
|
|
32
|
+
unlinkSync(portPath());
|
|
33
|
+
};
|
|
34
|
+
export const removeTokenFile = () => {
|
|
35
|
+
if (existsSync(tokenPath()))
|
|
36
|
+
unlinkSync(tokenPath());
|
|
37
|
+
};
|
|
38
|
+
const readNumberFile = (path) => {
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return null;
|
|
41
|
+
const n = Number.parseInt(readFileSync(path, 'utf-8').trim(), 10);
|
|
42
|
+
return Number.isNaN(n) ? null : n;
|
|
43
|
+
};
|
|
44
|
+
// Env override wins (test isolation); else the running daemon's recorded port; else the default.
|
|
45
|
+
export const resolvePort = () => {
|
|
46
|
+
const env = process.env['AGENTAGE_DAEMON_PORT'];
|
|
47
|
+
if (env) {
|
|
48
|
+
const n = Number.parseInt(env, 10);
|
|
49
|
+
if (!Number.isNaN(n))
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
return readNumberFile(portPath()) ?? DEFAULT_DAEMON_PORT;
|
|
53
|
+
};
|
|
54
|
+
const readPid = () => readNumberFile(pidPath());
|
|
55
|
+
export const isProcessAlive = (pid) => {
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// SIGTERM a known-live pid (a legacy daemon we learned about via /health, not our pidfile).
|
|
65
|
+
// Returns whether the signal was delivered; false when the pid is already gone or unsignalable.
|
|
66
|
+
export const signalPid = (pid) => {
|
|
67
|
+
if (!isProcessAlive(pid))
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 'SIGTERM');
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
export const isDaemonRunning = () => {
|
|
78
|
+
const pid = readPid();
|
|
79
|
+
if (pid === null)
|
|
80
|
+
return false;
|
|
81
|
+
if (!isProcessAlive(pid)) {
|
|
82
|
+
removePidFile();
|
|
83
|
+
removePortFile();
|
|
84
|
+
removeTokenFile();
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
};
|
|
89
|
+
// SIGTERM the running daemon and clear its on-disk state; the daemon also self-cleans on exit.
|
|
90
|
+
// Returns whether a live process was actually signalled.
|
|
91
|
+
export const stopDaemon = () => {
|
|
92
|
+
const pid = readPid();
|
|
93
|
+
if (pid === null)
|
|
94
|
+
return false;
|
|
95
|
+
const alive = isProcessAlive(pid);
|
|
96
|
+
if (alive)
|
|
97
|
+
process.kill(pid, 'SIGTERM');
|
|
98
|
+
removePidFile();
|
|
99
|
+
removePortFile();
|
|
100
|
+
removeTokenFile();
|
|
101
|
+
return alive;
|
|
102
|
+
};
|
|
103
|
+
// Lightweight, cycle-free /health probe (daemon-client imports this module) returning the reported
|
|
104
|
+
// pid; null when the port holds no reachable agentage daemon.
|
|
105
|
+
const probeDaemonPid = async (port) => {
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
108
|
+
signal: AbortSignal.timeout(500),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
return null;
|
|
112
|
+
const h = (await res.json());
|
|
113
|
+
return typeof h.pid === 'number' ? h.pid : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
// stopDaemon signals a bare recorded pid, which the OS may have recycled onto an unrelated process.
|
|
120
|
+
// Guard it: signal only when /health confirms the pid is our daemon, or - if no port was recorded -
|
|
121
|
+
// fall back to the blind signal with a printed caveat. Refuse otherwise rather than kill a stranger.
|
|
122
|
+
export const stopDaemonSafely = async (probe = probeDaemonPid) => {
|
|
123
|
+
const pid = readPid();
|
|
124
|
+
if (pid === null || !isProcessAlive(pid))
|
|
125
|
+
return stopDaemon();
|
|
126
|
+
const port = readNumberFile(portPath());
|
|
127
|
+
const confirmed = port !== null && (await probe(port)) === pid;
|
|
128
|
+
if (confirmed)
|
|
129
|
+
return stopDaemon();
|
|
130
|
+
if (port === null) {
|
|
131
|
+
console.error(chalk.yellow(`No recorded port; signalling pid ${pid} without confirmation.`));
|
|
132
|
+
return stopDaemon();
|
|
133
|
+
}
|
|
134
|
+
console.error(chalk.yellow(`Daemon on port ${port} did not confirm pid ${pid}; not signalling. Delete the pid file to force.`));
|
|
135
|
+
return false;
|
|
136
|
+
};
|
|
137
|
+
// Stop, then wait (bounded) for the old process to actually exit so a restart can rebind the
|
|
138
|
+
// port without an EADDRINUSE window. Returns whether the process is confirmed gone.
|
|
139
|
+
export const stopDaemonAndWait = async (timeoutMs = 2000) => {
|
|
140
|
+
const pid = readPid();
|
|
141
|
+
const signalled = stopDaemon();
|
|
142
|
+
if (!signalled || pid === null)
|
|
143
|
+
return true;
|
|
144
|
+
const deadline = Date.now() + timeoutMs;
|
|
145
|
+
while (isProcessAlive(pid)) {
|
|
146
|
+
if (Date.now() >= deadline)
|
|
147
|
+
return false;
|
|
148
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
export declare const handleMcp: (req: IncomingMessage, res: ServerResponse, buildServer: () => Promise<McpServer>, allowedHosts: string[]) => Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
2
|
+
const jsonRpcError = (res, status, message) => {
|
|
3
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
4
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message }, id: null }));
|
|
5
|
+
};
|
|
6
|
+
// Stateless Streamable HTTP (JSON, no session), mirroring the cloud memory endpoint: a fresh MCP
|
|
7
|
+
// server + transport per POST, torn down when the response closes. GET/DELETE are 405 - stateless
|
|
8
|
+
// exposes no server-initiated SSE stream or session teardown. Tokenless (editor clients stay
|
|
9
|
+
// simple) but DNS-rebinding-protected: allowedHosts pins the Host to the bound loopback port.
|
|
10
|
+
export const handleMcp = async (req, res, buildServer, allowedHosts) => {
|
|
11
|
+
if (req.method !== 'POST') {
|
|
12
|
+
jsonRpcError(res, 405, 'Method not allowed: this endpoint is stateless (POST only).');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const server = await buildServer();
|
|
16
|
+
const transport = new StreamableHTTPServerTransport({
|
|
17
|
+
sessionIdGenerator: undefined,
|
|
18
|
+
enableJsonResponse: true,
|
|
19
|
+
enableDnsRebindingProtection: true,
|
|
20
|
+
allowedHosts,
|
|
21
|
+
});
|
|
22
|
+
res.on('close', () => {
|
|
23
|
+
void transport.close();
|
|
24
|
+
void server.close();
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
await server.connect(transport);
|
|
28
|
+
await transport.handleRequest(req, res);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
if (!res.headersSent)
|
|
32
|
+
jsonRpcError(res, 500, 'Internal server error');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { type MemoryClient } from '../lib/memory/memory-client.js';
|
|
4
|
+
import { type SyncResult } from '../sync/git/cycle.js';
|
|
5
|
+
import { type CouchSyncResult } from '../sync/couch/manager.js';
|
|
6
|
+
import { type SyncStatus } from '../sync/git/manager.js';
|
|
7
|
+
import { type MemoryVerb } from './actions.js';
|
|
8
|
+
export interface DaemonSyncApi {
|
|
9
|
+
status: () => SyncStatus;
|
|
10
|
+
runNow: (vault: string) => Promise<SyncResult | CouchSyncResult>;
|
|
11
|
+
}
|
|
12
|
+
export interface DaemonServerOptions {
|
|
13
|
+
getClient: () => MemoryClient | Promise<MemoryClient>;
|
|
14
|
+
buildMcpServer?: () => Promise<McpServer>;
|
|
15
|
+
sync?: DaemonSyncApi;
|
|
16
|
+
onMutation?: (verb: MemoryVerb, body: unknown) => void;
|
|
17
|
+
authToken: string;
|
|
18
|
+
version: string;
|
|
19
|
+
startedAt?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface DaemonServer {
|
|
22
|
+
server: Server;
|
|
23
|
+
start: (port: number, host?: string) => Promise<void>;
|
|
24
|
+
stop: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare const createDaemonServer: (opts: DaemonServerOptions) => DaemonServer;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { dispatchMemory, isMemoryVerb } from './actions.js';
|
|
3
|
+
import { isAllowedHost, isAllowedOrigin, loopbackHosts } from './guards.js';
|
|
4
|
+
import { handleMcp } from './mcp-http.js';
|
|
5
|
+
const LOOPBACK = '127.0.0.1';
|
|
6
|
+
const AUTH_HEADER = 'x-agentage-token';
|
|
7
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
req.on('data', (c) => chunks.push(c));
|
|
10
|
+
req.on('end', () => {
|
|
11
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
12
|
+
if (raw.length === 0)
|
|
13
|
+
return resolve({});
|
|
14
|
+
try {
|
|
15
|
+
resolve(JSON.parse(raw));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
reject(new Error('invalid JSON body'));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
req.on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
const send = (res, status, body) => {
|
|
24
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
25
|
+
res.end(JSON.stringify(body));
|
|
26
|
+
};
|
|
27
|
+
const isJsonContentType = (req) => (req.headers['content-type'] ?? '').split(';')[0]?.trim() === 'application/json';
|
|
28
|
+
const header = (req, name) => {
|
|
29
|
+
const v = req.headers[name];
|
|
30
|
+
return Array.isArray(v) ? v[0] : v;
|
|
31
|
+
};
|
|
32
|
+
// Loopback-only JSON HTTP: GET /api/health + POST /api/memory/<verb> (the CLI verbs) + POST /mcp
|
|
33
|
+
// (the frozen 6 tools for on-machine AI clients, stateless Streamable HTTP). Web-origin defense on
|
|
34
|
+
// EVERY request (Host + Origin allow-lists + DNS-rebinding protection on /mcp); /api/* additionally
|
|
35
|
+
// needs the per-daemon token, while /health and /mcp stay tokenless for probes and editor clients.
|
|
36
|
+
export const createDaemonServer = (opts) => {
|
|
37
|
+
const startedAt = opts.startedAt ?? Date.now();
|
|
38
|
+
let served = 0;
|
|
39
|
+
let boundPort = 0;
|
|
40
|
+
// 403 unless the request looks like it came from a genuine same-machine loopback client.
|
|
41
|
+
const originGuard = (req) => isAllowedHost(header(req, 'host'), boundPort) && isAllowedOrigin(header(req, 'origin'));
|
|
42
|
+
const handle = async (req, res) => {
|
|
43
|
+
const url = req.url ?? '/';
|
|
44
|
+
if (!originGuard(req))
|
|
45
|
+
return send(res, 403, { error: 'forbidden' });
|
|
46
|
+
const path = url.split('?')[0] ?? url;
|
|
47
|
+
const isApi = path.startsWith('/api/') && path !== '/api/health';
|
|
48
|
+
if (isApi && header(req, AUTH_HEADER) !== opts.authToken) {
|
|
49
|
+
return send(res, 401, { error: 'unauthorized' });
|
|
50
|
+
}
|
|
51
|
+
if (isApi && req.method === 'POST' && !isJsonContentType(req)) {
|
|
52
|
+
return send(res, 415, { error: 'unsupported media type' });
|
|
53
|
+
}
|
|
54
|
+
if (req.method === 'GET' && url === '/api/health') {
|
|
55
|
+
return send(res, 200, {
|
|
56
|
+
ok: true,
|
|
57
|
+
version: opts.version,
|
|
58
|
+
pid: process.pid,
|
|
59
|
+
uptime: Math.floor((Date.now() - startedAt) / 1000),
|
|
60
|
+
served,
|
|
61
|
+
mcp: !!opts.buildMcpServer,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (path === '/mcp') {
|
|
65
|
+
if (!opts.buildMcpServer)
|
|
66
|
+
return send(res, 404, { error: 'not found' });
|
|
67
|
+
return handleMcp(req, res, opts.buildMcpServer, loopbackHosts(boundPort));
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'GET' && url === '/api/sync/status') {
|
|
70
|
+
if (!opts.sync)
|
|
71
|
+
return send(res, 404, { error: 'not found' });
|
|
72
|
+
return send(res, 200, opts.sync.status());
|
|
73
|
+
}
|
|
74
|
+
if (req.method === 'POST' && url === '/api/sync/run') {
|
|
75
|
+
if (!opts.sync)
|
|
76
|
+
return send(res, 404, { error: 'not found' });
|
|
77
|
+
const body = (await readBody(req));
|
|
78
|
+
if (!body.vault)
|
|
79
|
+
return send(res, 400, { error: 'vault is required' });
|
|
80
|
+
try {
|
|
81
|
+
return send(res, 200, await opts.sync.runNow(body.vault));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return send(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const match = url.match(/^\/api\/memory\/([a-z]+)$/);
|
|
88
|
+
if (req.method === 'POST' && match) {
|
|
89
|
+
const verb = match[1];
|
|
90
|
+
if (!isMemoryVerb(verb))
|
|
91
|
+
return send(res, 404, { error: `unknown verb: ${verb}` });
|
|
92
|
+
try {
|
|
93
|
+
const body = await readBody(req);
|
|
94
|
+
const result = await dispatchMemory(await opts.getClient(), verb, body);
|
|
95
|
+
served += 1;
|
|
96
|
+
opts.onMutation?.(verb, body); // fire-and-forget account push-on-save
|
|
97
|
+
return send(res, 200, result);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return send(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
send(res, 404, { error: 'not found' });
|
|
104
|
+
};
|
|
105
|
+
const server = createServer((req, res) => {
|
|
106
|
+
handle(req, res).catch((err) => send(res, 500, { error: err instanceof Error ? err.message : String(err) }));
|
|
107
|
+
});
|
|
108
|
+
const start = (port, host = LOOPBACK) => new Promise((resolve, reject) => {
|
|
109
|
+
const onError = (err) => {
|
|
110
|
+
if (err.code !== 'EADDRINUSE')
|
|
111
|
+
return reject(err);
|
|
112
|
+
// Preserve the code so the entry point can exit distinctly on a busy port.
|
|
113
|
+
const busy = new Error(`port ${port} already in use`);
|
|
114
|
+
busy.code = 'EADDRINUSE';
|
|
115
|
+
reject(busy);
|
|
116
|
+
};
|
|
117
|
+
server.once('error', onError);
|
|
118
|
+
server.listen(port, host, () => {
|
|
119
|
+
const addr = server.address();
|
|
120
|
+
boundPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
121
|
+
server.removeListener('error', onError);
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
// close() alone hangs on idle keep-alive sockets; drop idle ones now, force the rest after a grace.
|
|
126
|
+
const stop = () => new Promise((resolve) => {
|
|
127
|
+
server.closeIdleConnections();
|
|
128
|
+
const grace = setTimeout(() => server.closeAllConnections(), 500);
|
|
129
|
+
grace.unref();
|
|
130
|
+
server.close(() => {
|
|
131
|
+
clearTimeout(grace);
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
return { server, start, stop };
|
|
136
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const isEaddrinuse: (err: unknown) => boolean;
|
|
2
|
+
export declare const createStateCleanup: (remove: () => void) => {
|
|
3
|
+
markOwned: () => void;
|
|
4
|
+
cleanup: () => void;
|
|
5
|
+
};
|
|
6
|
+
export declare const safeReschedule: (steps: Array<() => void>, onError: (msg: string) => void) => void;
|
|
7
|
+
export declare const mcpEnabled: (env?: NodeJS.ProcessEnv) => boolean;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { unwatchFile, watchFile } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { isAccountVault } from '@agentage/memory-core';
|
|
4
|
+
import { createClientProvider } from './daemon/client-provider.js';
|
|
5
|
+
import { EADDRINUSE_EXIT_CODE, generateDaemonToken, removePidFile, removePortFile, removeTokenFile, resolvePort, writePidFile, writePortFile, writeTokenFile, } from './daemon/lifecycle.js';
|
|
6
|
+
import { createDaemonServer } from './daemon/server.js';
|
|
7
|
+
import { loadLocalMemoryServer } from './mcp/local-server.js';
|
|
8
|
+
import { loadVaultsConfig, vaultsJsonPath } from './lib/vault/vaults.js';
|
|
9
|
+
import { createCouchSyncManager } from './sync/couch/manager.js';
|
|
10
|
+
import { createDiscoverWatcher } from './sync/discover/watcher.js';
|
|
11
|
+
import { createSyncManager } from './sync/git/manager.js';
|
|
12
|
+
import { VERSION } from './utils/version.js';
|
|
13
|
+
export const isEaddrinuse = (err) => typeof err === 'object' && err !== null && err.code === 'EADDRINUSE';
|
|
14
|
+
// Gate state-file cleanup on ownership: a loser of an autostart race must never wipe the winner's
|
|
15
|
+
// pid/port/token files. Only the process that actually wrote them may remove them.
|
|
16
|
+
export const createStateCleanup = (remove) => {
|
|
17
|
+
let owned = false;
|
|
18
|
+
return {
|
|
19
|
+
markOwned: () => {
|
|
20
|
+
owned = true;
|
|
21
|
+
},
|
|
22
|
+
cleanup: () => {
|
|
23
|
+
if (owned)
|
|
24
|
+
remove();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
// Run each reschedule independently: a transiently-invalid config edit must not crash the daemon or
|
|
29
|
+
// stop the other channels rescheduling; the throwing one keeps its last-good schedule.
|
|
30
|
+
export const safeReschedule = (steps, onError) => {
|
|
31
|
+
for (const step of steps) {
|
|
32
|
+
try {
|
|
33
|
+
step();
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
onError(err instanceof Error ? err.message : String(err));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
// MCP is served at /mcp by default; AGENTAGE_DAEMON_NO_MCP=1 leaves buildMcpServer unset so the
|
|
41
|
+
// endpoint 404s (the `daemon start --no-mcp` flag sets this on the spawned process).
|
|
42
|
+
export const mcpEnabled = (env = process.env) => env['AGENTAGE_DAEMON_NO_MCP'] !== '1';
|
|
43
|
+
// Unset/empty/invalid -> undefined (the watcher's defaults apply); the watcher floors low values.
|
|
44
|
+
const envInt = (name) => {
|
|
45
|
+
const raw = process.env[name];
|
|
46
|
+
if (!raw)
|
|
47
|
+
return undefined;
|
|
48
|
+
const v = Number(raw);
|
|
49
|
+
return Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
50
|
+
};
|
|
51
|
+
const state = createStateCleanup(() => {
|
|
52
|
+
removePidFile();
|
|
53
|
+
removePortFile();
|
|
54
|
+
removeTokenFile();
|
|
55
|
+
});
|
|
56
|
+
// The detached, long-lived engine host: one loopback HTTP server that owns a single in-process
|
|
57
|
+
// engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. It
|
|
58
|
+
// runs both sync loops (git origins + the account/couch channel) and reschedules on config change.
|
|
59
|
+
const main = async () => {
|
|
60
|
+
const port = resolvePort();
|
|
61
|
+
const authToken = generateDaemonToken();
|
|
62
|
+
const git = createSyncManager();
|
|
63
|
+
const couch = createCouchSyncManager();
|
|
64
|
+
const discover = createDiscoverWatcher({
|
|
65
|
+
log: (msg) => console.log(`[discover] ${msg}`),
|
|
66
|
+
debounceMs: envInt('AGENTAGE_DISCOVER_DEBOUNCE_MS'),
|
|
67
|
+
pollMs: envInt('AGENTAGE_DISCOVER_POLL_MS'),
|
|
68
|
+
});
|
|
69
|
+
// A vault is on exactly one channel: an account (agentage) vault syncs over couch, else git.
|
|
70
|
+
const runNow = (vault) => {
|
|
71
|
+
const entry = loadVaultsConfig().config.vaults?.[vault];
|
|
72
|
+
return entry && isAccountVault(entry) ? couch.runNow(vault) : git.runNow(vault);
|
|
73
|
+
};
|
|
74
|
+
const server = createDaemonServer({
|
|
75
|
+
getClient: createClientProvider(),
|
|
76
|
+
buildMcpServer: mcpEnabled() ? loadLocalMemoryServer : undefined,
|
|
77
|
+
sync: {
|
|
78
|
+
status: () => ({ ...git.status(), couch: couch.status(), discover: discover.status() }),
|
|
79
|
+
runNow,
|
|
80
|
+
},
|
|
81
|
+
onMutation: (verb, body) => couch.onWrite(verb, body),
|
|
82
|
+
authToken,
|
|
83
|
+
version: VERSION,
|
|
84
|
+
});
|
|
85
|
+
await server.start(port);
|
|
86
|
+
writePidFile(process.pid);
|
|
87
|
+
writePortFile(port);
|
|
88
|
+
writeTokenFile(authToken);
|
|
89
|
+
state.markOwned();
|
|
90
|
+
const reschedule = () => safeReschedule([() => git.reschedule(), () => couch.reschedule(), () => discover.reschedule()], (msg) => console.error(`[daemon] reschedule failed: ${msg}`));
|
|
91
|
+
reschedule();
|
|
92
|
+
const configPath = vaultsJsonPath();
|
|
93
|
+
watchFile(configPath, { interval: 2000 }, reschedule);
|
|
94
|
+
const shutdown = () => {
|
|
95
|
+
unwatchFile(configPath);
|
|
96
|
+
git.stop();
|
|
97
|
+
couch.stop();
|
|
98
|
+
discover.stop();
|
|
99
|
+
server.stop().finally(() => {
|
|
100
|
+
state.cleanup();
|
|
101
|
+
process.exit(0);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
process.on('SIGTERM', shutdown);
|
|
105
|
+
process.on('SIGINT', shutdown);
|
|
106
|
+
};
|
|
107
|
+
// Only self-invoke when run directly (spawnDaemon's `node daemon-entry.js`); importing for tests
|
|
108
|
+
// must not boot a daemon.
|
|
109
|
+
const invokedDirectly = () => {
|
|
110
|
+
const entry = process.argv[1];
|
|
111
|
+
return !!entry && fileURLToPath(import.meta.url) === entry;
|
|
112
|
+
};
|
|
113
|
+
if (invokedDirectly()) {
|
|
114
|
+
process.on('uncaughtException', (err) => {
|
|
115
|
+
console.error(`[daemon] uncaught: ${err instanceof Error ? err.message : String(err)}`);
|
|
116
|
+
state.cleanup();
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
if (isEaddrinuse(err)) {
|
|
121
|
+
// Another daemon owns the port + our state files: exit distinctly, touch nothing.
|
|
122
|
+
process.exit(EADDRINUSE_EXIT_CODE);
|
|
123
|
+
}
|
|
124
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
125
|
+
state.cleanup();
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
|
128
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type AuthState } from '../fs/config.js';
|
|
2
|
+
import { type Links } from '../net/origins.js';
|
|
3
|
+
export { AuthRequiredError, TransientAuthError } from './auth-errors.js';
|
|
4
|
+
export declare const refreshOrThrow: (auth: AuthState, links: Links) => Promise<void>;
|
|
5
|
+
export declare const currentBearer: (readAuth: () => AuthState | null, links: Links) => Promise<string | null>;
|
|
6
|
+
export declare const authedGet: <T>(auth: AuthState, links: Links, url: string) => Promise<T>;
|
|
7
|
+
export declare const authedPost: (auth: AuthState, links: Links, url: string, body: unknown) => Promise<Response>;
|
|
8
|
+
export { introspectToken, type TokenSession } from './introspect.js';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mutateAuth } from '../fs/config.js';
|
|
2
|
+
import { refreshTokens, TokenRequestError } from './oauth.js';
|
|
3
|
+
import { requestHeaders } from '../net/user-agent.js';
|
|
4
|
+
import { AuthRequiredError, isTerminalRefresh, TransientAuthError } from './auth-errors.js';
|
|
5
|
+
export { AuthRequiredError, TransientAuthError } from './auth-errors.js';
|
|
6
|
+
// Every authed call is CLI-originated; identify the caller alongside the bearer.
|
|
7
|
+
const cliHeaders = () => requestHeaders({ component: 'cli' });
|
|
8
|
+
// Refresh the stored token in place. On success mutates auth + persists and returns. On failure it
|
|
9
|
+
// throws: AuthRequiredError when the grant is dead (401/invalid_grant/invalid_client), else
|
|
10
|
+
// TransientAuthError (429/5xx/network/timeout) - a blip that must not be read as a dead session.
|
|
11
|
+
export const refreshOrThrow = async (auth, links) => {
|
|
12
|
+
if (auth.kind === 'pat')
|
|
13
|
+
throw new AuthRequiredError('personal access token expired or revoked - mint a new one in the dashboard (Settings -> API tokens)');
|
|
14
|
+
if (!auth.tokens.refreshToken)
|
|
15
|
+
throw new AuthRequiredError('no refresh token');
|
|
16
|
+
let fresh;
|
|
17
|
+
try {
|
|
18
|
+
fresh = await refreshTokens(links.auth, auth.clientId, auth.tokens.refreshToken);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err instanceof TokenRequestError && isTerminalRefresh(err.status, err.oauthError))
|
|
22
|
+
throw new AuthRequiredError('session expired');
|
|
23
|
+
throw new TransientAuthError('refresh temporarily failed');
|
|
24
|
+
}
|
|
25
|
+
const tokens = {
|
|
26
|
+
accessToken: fresh.access_token,
|
|
27
|
+
refreshToken: fresh.refresh_token ?? auth.tokens.refreshToken,
|
|
28
|
+
expiresAt: fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined,
|
|
29
|
+
};
|
|
30
|
+
auth.tokens = tokens; // the caller retries its request with this in-memory copy
|
|
31
|
+
// Persist under the lock, folding the new tokens onto the freshly-read state so a concurrent
|
|
32
|
+
// writer's other fields (clientId, siteFqdn) are not clobbered by a stale in-memory copy.
|
|
33
|
+
await mutateAuth((current) => {
|
|
34
|
+
const base = current ?? auth;
|
|
35
|
+
base.tokens = tokens;
|
|
36
|
+
return base;
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
// Boolean shim for the request paths that just want "refresh once, then retry": any failure
|
|
40
|
+
// (terminal or transient) collapses to false so they fall through to their own status handling.
|
|
41
|
+
const tryRefresh = async (auth, links) => {
|
|
42
|
+
try {
|
|
43
|
+
await refreshOrThrow(auth, links);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
// The current OAuth bearer for background (couch) sync. Reads auth.json fresh on every call - the
|
|
51
|
+
// user may sign in or out between ticks - and refreshes once when the stored token is past its
|
|
52
|
+
// stated expiry. Returns null when signed out so a caller pauses with zero network, never throws.
|
|
53
|
+
export const currentBearer = async (readAuth, links) => {
|
|
54
|
+
const auth = readAuth();
|
|
55
|
+
if (!auth?.tokens.accessToken)
|
|
56
|
+
return null;
|
|
57
|
+
const expired = auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt <= Date.now();
|
|
58
|
+
if (expired && !(await tryRefresh(auth, links)))
|
|
59
|
+
return null;
|
|
60
|
+
return auth.tokens.accessToken;
|
|
61
|
+
};
|
|
62
|
+
// redirect: 'manual' so the bearer is never replayed to a redirect target; any 3xx is an error.
|
|
63
|
+
const isRedirect = (res) => res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400);
|
|
64
|
+
export const authedGet = async (auth, links, url) => {
|
|
65
|
+
const call = () => fetch(url, {
|
|
66
|
+
headers: { authorization: `Bearer ${auth.tokens.accessToken}`, ...cliHeaders() },
|
|
67
|
+
redirect: 'manual',
|
|
68
|
+
});
|
|
69
|
+
let res = await call();
|
|
70
|
+
if (res.status === 401 && (await tryRefresh(auth, links)))
|
|
71
|
+
res = await call();
|
|
72
|
+
if (res.status === 401)
|
|
73
|
+
throw new AuthRequiredError('session expired');
|
|
74
|
+
if (isRedirect(res))
|
|
75
|
+
throw new Error(`GET ${url} refused redirect (${res.status})`);
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error(`GET ${url} failed (${res.status})`);
|
|
78
|
+
return (await res.json());
|
|
79
|
+
};
|
|
80
|
+
// A bearer POST with the same refresh-once dance as authedGet. Unlike authedGet it returns the
|
|
81
|
+
// raw Response (never throws on a non-2xx): callers branch on the status codes themselves.
|
|
82
|
+
export const authedPost = async (auth, links, url, body) => {
|
|
83
|
+
const call = () => fetch(url, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
authorization: `Bearer ${auth.tokens.accessToken}`,
|
|
87
|
+
'content-type': 'application/json',
|
|
88
|
+
...cliHeaders(),
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
redirect: 'manual',
|
|
92
|
+
});
|
|
93
|
+
let res = await call();
|
|
94
|
+
if (res.status === 401 && (await tryRefresh(auth, links)))
|
|
95
|
+
res = await call();
|
|
96
|
+
return res;
|
|
97
|
+
};
|
|
98
|
+
// Re-export the introspection surface so existing callers keep importing from './api.js'.
|
|
99
|
+
export { introspectToken } from './introspect.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare class AuthRequiredError extends Error {
|
|
2
|
+
constructor(message?: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class TransientAuthError extends Error {
|
|
5
|
+
constructor(message?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare const isTerminalRefresh: (status: number, oauthError?: string) => boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Auth failures split two ways: TERMINAL means the session truly needs re-auth (401,
|
|
2
|
+
// invalid_grant/invalid_client on the refresh grant); TRANSIENT means a blip we must not report as
|
|
3
|
+
// expired (429, 5xx, network, timeout, unexpected redirect). Mirrors web#219's get-session taxonomy.
|
|
4
|
+
// The session is genuinely gone: user must sign in again.
|
|
5
|
+
export class AuthRequiredError extends Error {
|
|
6
|
+
constructor(message = 'not signed in') {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'AuthRequiredError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// A temporary failure (rate limit / server hiccup / network) - the session may still be valid.
|
|
12
|
+
export class TransientAuthError extends Error {
|
|
13
|
+
constructor(message = 'could not verify session') {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'TransientAuthError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// OAuth token-endpoint error codes that mean the refresh grant is dead, not merely throttled.
|
|
19
|
+
const TERMINAL_GRANT_ERRORS = new Set(['invalid_grant', 'invalid_client']);
|
|
20
|
+
// Classify a failed refresh POST: 401 or an explicit invalid_grant/invalid_client is terminal;
|
|
21
|
+
// everything else (429, 5xx, network, redirect, unknown) is transient.
|
|
22
|
+
export const isTerminalRefresh = (status, oauthError) => status === 401 || (oauthError !== undefined && TERMINAL_GRANT_ERRORS.has(oauthError));
|
|
@@ -39,7 +39,8 @@ export const startCallbackServer = (expectedState) => new Promise((resolveServer
|
|
|
39
39
|
.end(page(true, 'You can return to the terminal.'));
|
|
40
40
|
finish(code);
|
|
41
41
|
});
|
|
42
|
-
|
|
42
|
+
// Bind loopback only so the one-shot sign-in callback is never LAN-reachable.
|
|
43
|
+
server.listen(0, '127.0.0.1', () => {
|
|
43
44
|
const address = server.address();
|
|
44
45
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
45
46
|
resolveServer({
|
|
@@ -69,4 +70,3 @@ export const startCallbackServer = (expectedState) => new Promise((resolveServer
|
|
|
69
70
|
});
|
|
70
71
|
});
|
|
71
72
|
});
|
|
72
|
-
//# sourceMappingURL=callback-server.js.map
|