@stage-labs/metro 0.1.0-beta.31 → 0.1.0-beta.32
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/dist/cli.js +5 -0
- package/dist/runtime.js +4 -1
- package/dist/serve.js +57 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +16 -15
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -9
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +8 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +109 -71
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +16 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +14 -14
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +4 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +9 -9
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +9 -9
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +31 -31
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +48 -39
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +7 -7
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +22 -12
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { log } from './log.js';
|
|
3
|
+
import {
|
|
4
|
+
apiFailure,
|
|
5
|
+
bodyField,
|
|
6
|
+
cors,
|
|
7
|
+
readJsonBody,
|
|
8
|
+
sendJson,
|
|
9
|
+
} from './api-http.js';
|
|
10
|
+
import { mintNonce, takeNonce } from './siwe-nonces.js';
|
|
11
|
+
import { verifySiweLogin } from './siwe-auth.js';
|
|
12
|
+
import { signSession } from './session.js';
|
|
13
|
+
import { sessionTtlFromEnv } from './session-config.js';
|
|
14
|
+
import { ensureUserWithProject } from '../db/projects.js';
|
|
15
|
+
|
|
16
|
+
const NONCE_PATH = '/auth/siwe/nonce';
|
|
17
|
+
const VERIFY_PATH = '/auth/siwe/verify';
|
|
18
|
+
|
|
19
|
+
export interface SiweRouteDeps {
|
|
20
|
+
ensureUser: (address: string) => Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultDeps: SiweRouteDeps = { ensureUser: ensureUserWithProject };
|
|
24
|
+
|
|
25
|
+
async function handleVerify(
|
|
26
|
+
req: IncomingMessage,
|
|
27
|
+
res: ServerResponse,
|
|
28
|
+
secret: string,
|
|
29
|
+
deps: SiweRouteDeps,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const body = await readJsonBody(req);
|
|
32
|
+
const message = bodyField(body, 'message');
|
|
33
|
+
const signature = bodyField(body, 'signature');
|
|
34
|
+
if (typeof message !== 'string' || typeof signature !== 'string') {
|
|
35
|
+
sendJson(req, res, 400, { error: 'message and signature are required' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const address = await verifySiweLogin({ message, signature }, { takeNonce });
|
|
39
|
+
const userId = await deps.ensureUser(address);
|
|
40
|
+
log.info({ userId, address }, 'siwe: session issued');
|
|
41
|
+
const session = signSession({ subject: address, agentIds: [] }, secret, {
|
|
42
|
+
ttlSec: sessionTtlFromEnv(),
|
|
43
|
+
});
|
|
44
|
+
sendJson(req, res, 200, { session, address });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ROUTES: Record<string, string> = {
|
|
48
|
+
[NONCE_PATH]: 'GET',
|
|
49
|
+
[VERIFY_PATH]: 'POST',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function refused(
|
|
53
|
+
req: IncomingMessage,
|
|
54
|
+
res: ServerResponse,
|
|
55
|
+
path: string,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (req.method === 'OPTIONS') {
|
|
58
|
+
res.writeHead(204, cors(req)).end();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (req.method !== ROUTES[path]) {
|
|
62
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function handleSiweAuthRequest(
|
|
69
|
+
req: IncomingMessage,
|
|
70
|
+
res: ServerResponse,
|
|
71
|
+
deps: SiweRouteDeps = defaultDeps,
|
|
72
|
+
): boolean {
|
|
73
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
74
|
+
if (ROUTES[path] === undefined) return false;
|
|
75
|
+
if (refused(req, res, path)) return true;
|
|
76
|
+
const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
|
|
77
|
+
if (secret === '') {
|
|
78
|
+
sendJson(req, res, 503, { error: 'sign-in is not configured' });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (path === NONCE_PATH) {
|
|
82
|
+
sendJson(req, res, 200, { nonce: mintNonce() });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
handleVerify(req, res, secret, deps).catch((err: unknown) => {
|
|
86
|
+
apiFailure(req, res, err, 'siwe');
|
|
87
|
+
});
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { mkdirSync, statSync, type FSWatcher } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { errMsg, log } from './log.js';
|
|
5
4
|
import { daemonSelf } from './events.js';
|
|
@@ -22,9 +21,7 @@ import {
|
|
|
22
21
|
startWatcher,
|
|
23
22
|
type TrainState,
|
|
24
23
|
} from './supervisor-io.js';
|
|
25
|
-
|
|
26
|
-
export const TRAINS_DIR =
|
|
27
|
-
process.env.METRO_TRAINS_DIR ?? join(homedir(), '.metro', 'trains');
|
|
24
|
+
import { trainsDir } from './paths.js';
|
|
28
25
|
|
|
29
26
|
const DYLD_FALLBACK_ENV: Record<string, string> =
|
|
30
27
|
process.platform === 'darwin'
|
|
@@ -45,7 +42,7 @@ export class TrainSupervisor {
|
|
|
45
42
|
private watcher: FSWatcher | null = null;
|
|
46
43
|
private reloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
47
44
|
|
|
48
|
-
constructor(private dir: string =
|
|
45
|
+
constructor(private dir: string = trainsDir()) {}
|
|
49
46
|
|
|
50
47
|
running(): string[] {
|
|
51
48
|
return [...this.trains.keys()];
|
|
@@ -3,12 +3,11 @@ import { and, asc, eq, inArray } from 'drizzle-orm';
|
|
|
3
3
|
import { ApiError } from '../daemon/api-error.js';
|
|
4
4
|
import { getDb } from './client.js';
|
|
5
5
|
import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
|
|
6
|
-
import { newId, parseId } from './ids.js';
|
|
6
|
+
import { AGENT_NAME_RE, newId, parseId } from './ids.js';
|
|
7
7
|
import { agents, stations } from './schema.js';
|
|
8
8
|
import { isUniqueViolation } from './users.js';
|
|
9
9
|
import { projectIdOrThrow } from './projects.js';
|
|
10
10
|
|
|
11
|
-
export const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,31}$/;
|
|
12
11
|
const KEY_ATTEMPTS = 5;
|
|
13
12
|
|
|
14
13
|
export class AgentAdminError extends ApiError {}
|
|
@@ -93,11 +92,11 @@ async function selectKeyRows(ids: string[]): Promise<KeyRow[]> {
|
|
|
93
92
|
.where(inArray(agents.id, ids));
|
|
94
93
|
}
|
|
95
94
|
|
|
96
|
-
export async function
|
|
97
|
-
|
|
95
|
+
export async function listAgentsForUser(
|
|
96
|
+
subject: string,
|
|
98
97
|
project: string,
|
|
99
98
|
): Promise<AgentSummary[]> {
|
|
100
|
-
const projectId = await projectIdOrThrow(
|
|
99
|
+
const projectId = await projectIdOrThrow(subject, project);
|
|
101
100
|
const rows = await getDb()
|
|
102
101
|
.select({ id: agents.id, name: agents.name, projectId: agents.projectId })
|
|
103
102
|
.from(agents)
|
|
@@ -123,14 +122,14 @@ async function insertAgent(
|
|
|
123
122
|
return id;
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
export async function
|
|
127
|
-
|
|
125
|
+
export async function createAgentForUser(
|
|
126
|
+
subject: string,
|
|
128
127
|
project: string,
|
|
129
128
|
rawName: string,
|
|
130
129
|
): Promise<CreatedAgent> {
|
|
131
130
|
const name = normalizeAgentName(rawName);
|
|
132
131
|
const key = newApiKey();
|
|
133
|
-
const id = await insertAgent(await projectIdOrThrow(
|
|
132
|
+
const id = await insertAgent(await projectIdOrThrow(subject, project), name, key);
|
|
134
133
|
if (servesEveryAgent()) registerKey(key, id);
|
|
135
134
|
return { id, name, key };
|
|
136
135
|
}
|
|
@@ -145,7 +144,7 @@ interface Deletable {
|
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
export async function ownedAgentOrThrow(
|
|
148
|
-
|
|
147
|
+
subject: string,
|
|
149
148
|
id: string,
|
|
150
149
|
): Promise<Deletable> {
|
|
151
150
|
const rows = await getDb().select().from(agents).where(eq(agents.id, id));
|
|
@@ -153,7 +152,7 @@ export async function ownedAgentOrThrow(
|
|
|
153
152
|
const missing = new AgentAdminError('no such agent', 404);
|
|
154
153
|
if (!row) throw missing;
|
|
155
154
|
try {
|
|
156
|
-
await projectIdOrThrow(
|
|
155
|
+
await projectIdOrThrow(subject, row.projectId);
|
|
157
156
|
} catch {
|
|
158
157
|
throw missing;
|
|
159
158
|
}
|
|
@@ -179,21 +178,21 @@ async function writeNewKey(id: string, projectId: string): Promise<string> {
|
|
|
179
178
|
throw new AgentAdminError('could not allocate a free api key', 500);
|
|
180
179
|
}
|
|
181
180
|
|
|
182
|
-
export async function
|
|
183
|
-
|
|
181
|
+
export async function resetAgentKeyForUser(
|
|
182
|
+
subject: string,
|
|
184
183
|
id: string,
|
|
185
184
|
): Promise<ResetAgentKey> {
|
|
186
|
-
const { agent, projectId } = await ownedAgentOrThrow(
|
|
185
|
+
const { agent, projectId } = await ownedAgentOrThrow(subject, id);
|
|
187
186
|
const key = await writeNewKey(agent.id, projectId);
|
|
188
187
|
rotateAgentKey(agent.id, daemonServesAgent(agent.id) ? key : null);
|
|
189
188
|
return { id: agent.id, name: agent.name, key };
|
|
190
189
|
}
|
|
191
190
|
|
|
192
|
-
export async function
|
|
193
|
-
|
|
191
|
+
export async function deleteAgentForUser(
|
|
192
|
+
subject: string,
|
|
194
193
|
id: string,
|
|
195
194
|
): Promise<DeletedAgent> {
|
|
196
|
-
const { agent, projectId } = await ownedAgentOrThrow(
|
|
195
|
+
const { agent, projectId } = await ownedAgentOrThrow(subject, id);
|
|
197
196
|
await getDb().transaction(async (tx) => {
|
|
198
197
|
const attached = await tx
|
|
199
198
|
.select({ id: stations.id })
|
|
@@ -86,11 +86,11 @@ export async function connectorIdsByAgent(
|
|
|
86
86
|
return out;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
export async function
|
|
90
|
-
|
|
89
|
+
export async function agentConnectorsForUser(
|
|
90
|
+
subject: string,
|
|
91
91
|
agentId: string,
|
|
92
92
|
): Promise<AgentConnectors> {
|
|
93
|
-
const { agent } = await ownedAgentOrThrow(
|
|
93
|
+
const { agent } = await ownedAgentOrThrow(subject, agentId);
|
|
94
94
|
return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -105,12 +105,12 @@ async function connectorNameIn(
|
|
|
105
105
|
return rows[0]?.name ?? null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
export async function
|
|
109
|
-
|
|
108
|
+
export async function addConnectorToAgentForUser(
|
|
109
|
+
subject: string,
|
|
110
110
|
agentId: string,
|
|
111
111
|
connectorId: string,
|
|
112
112
|
): Promise<AgentConnectors> {
|
|
113
|
-
const { agent, projectId } = await ownedAgentOrThrow(
|
|
113
|
+
const { agent, projectId } = await ownedAgentOrThrow(subject, agentId);
|
|
114
114
|
const name = await connectorNameIn(projectId, connectorId);
|
|
115
115
|
if (name === null) throw new ConnectorError('no such connector', 404);
|
|
116
116
|
const clash = await collidingAgent([agent.id], name, connectorId);
|
|
@@ -125,12 +125,12 @@ export async function addConnectorToAgentForEmail(
|
|
|
125
125
|
return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
export async function
|
|
129
|
-
|
|
128
|
+
export async function removeConnectorFromAgentForUser(
|
|
129
|
+
subject: string,
|
|
130
130
|
agentId: string,
|
|
131
131
|
connectorId: string,
|
|
132
132
|
): Promise<AgentConnectors> {
|
|
133
|
-
const { agent } = await ownedAgentOrThrow(
|
|
133
|
+
const { agent } = await ownedAgentOrThrow(subject, agentId);
|
|
134
134
|
await getDb()
|
|
135
135
|
.delete(agentConnectors)
|
|
136
136
|
.where(
|
|
@@ -80,7 +80,7 @@ const missing = (): ConnectorError =>
|
|
|
80
80
|
new ConnectorError('no such connector', 404);
|
|
81
81
|
|
|
82
82
|
async function ownedConnectorOrThrow(
|
|
83
|
-
|
|
83
|
+
subject: string,
|
|
84
84
|
id: string,
|
|
85
85
|
): Promise<ConnectorRow> {
|
|
86
86
|
const rows = await getDb()
|
|
@@ -90,7 +90,7 @@ async function ownedConnectorOrThrow(
|
|
|
90
90
|
const row = rows[0];
|
|
91
91
|
if (row === undefined) throw missing();
|
|
92
92
|
try {
|
|
93
|
-
await projectIdOrThrow(
|
|
93
|
+
await projectIdOrThrow(subject, row.projectId);
|
|
94
94
|
} catch {
|
|
95
95
|
throw missing();
|
|
96
96
|
}
|
|
@@ -112,10 +112,10 @@ async function saveConfig(
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
async function connectorRowsFor(
|
|
115
|
-
|
|
115
|
+
subject: string,
|
|
116
116
|
project: string,
|
|
117
117
|
): Promise<ConnectorRow[]> {
|
|
118
|
-
const projectId = await projectIdOrThrow(
|
|
118
|
+
const projectId = await projectIdOrThrow(subject, project);
|
|
119
119
|
return getDb()
|
|
120
120
|
.select()
|
|
121
121
|
.from(connectors)
|
|
@@ -123,11 +123,11 @@ async function connectorRowsFor(
|
|
|
123
123
|
.orderBy(asc(connectors.id));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export async function
|
|
127
|
-
|
|
126
|
+
export async function listConnectorsForUser(
|
|
127
|
+
subject: string,
|
|
128
128
|
project: string,
|
|
129
129
|
): Promise<Connector[]> {
|
|
130
|
-
return (await connectorRowsFor(
|
|
130
|
+
return (await connectorRowsFor(subject, project)).map(toConnector);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
async function rowsByIds(ids: string[]): Promise<ConnectorRow[]> {
|
|
@@ -145,11 +145,11 @@ export async function connectorNamesByIds(
|
|
|
145
145
|
return (await rowsByIds(ids)).map((row) => ({ id: row.id, name: row.name }));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
export async function
|
|
149
|
-
|
|
148
|
+
export async function getConnectorForUser(
|
|
149
|
+
subject: string,
|
|
150
150
|
id: string,
|
|
151
151
|
): Promise<Connector> {
|
|
152
|
-
return toConnector(await ownedConnectorOrThrow(
|
|
152
|
+
return toConnector(await ownedConnectorOrThrow(subject, id));
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
async function insertConnector(
|
|
@@ -185,14 +185,14 @@ const UNVERIFIED = {
|
|
|
185
185
|
catalog: [],
|
|
186
186
|
};
|
|
187
187
|
|
|
188
|
-
export async function
|
|
189
|
-
|
|
188
|
+
export async function createPendingConnectorForUser(
|
|
189
|
+
subject: string,
|
|
190
190
|
project: string,
|
|
191
191
|
input: { name: unknown; url: unknown },
|
|
192
192
|
): Promise<Connector> {
|
|
193
193
|
const name = connectorName(input.name);
|
|
194
194
|
const url = parseConnectorUrl(input.url);
|
|
195
|
-
const projectId = await projectIdOrThrow(
|
|
195
|
+
const projectId = await projectIdOrThrow(subject, project);
|
|
196
196
|
return insertConnector(projectId, name, url, {
|
|
197
197
|
auth: { kind: 'none' },
|
|
198
198
|
createdAt: new Date().toISOString(),
|
|
@@ -201,15 +201,15 @@ export async function createPendingConnectorForEmail(
|
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
export async function
|
|
205
|
-
|
|
204
|
+
export async function createConnectorForUser(
|
|
205
|
+
subject: string,
|
|
206
206
|
project: string,
|
|
207
207
|
input: ConnectorInput,
|
|
208
208
|
): Promise<Connector> {
|
|
209
209
|
const name = connectorName(input.name);
|
|
210
210
|
const url = parseConnectorUrl(input.url);
|
|
211
211
|
const auth = connectorAuth(input.header, input.value);
|
|
212
|
-
const projectId = await projectIdOrThrow(
|
|
212
|
+
const projectId = await projectIdOrThrow(subject, project);
|
|
213
213
|
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
214
214
|
return insertConnector(projectId, name, url, {
|
|
215
215
|
auth,
|
|
@@ -233,34 +233,34 @@ async function freshAuth(
|
|
|
233
233
|
return refreshOAuth(auth, resource);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
export async function
|
|
237
|
-
|
|
236
|
+
export async function reconnectConnectorForUser(
|
|
237
|
+
subject: string,
|
|
238
238
|
id: string,
|
|
239
239
|
auth: OAuthAuth,
|
|
240
240
|
): Promise<Connector> {
|
|
241
|
-
const row = await ownedConnectorOrThrow(
|
|
241
|
+
const row = await ownedConnectorOrThrow(subject, id);
|
|
242
242
|
const config = readConfig(row.config);
|
|
243
243
|
const url = parseConnectorUrl(row.url);
|
|
244
244
|
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
245
245
|
return saveConfig(row, { ...config, auth, verified, oauth: true });
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
export async function
|
|
249
|
-
|
|
248
|
+
export async function disconnectConnectorForUser(
|
|
249
|
+
subject: string,
|
|
250
250
|
id: string,
|
|
251
251
|
): Promise<Connector> {
|
|
252
|
-
const row = await ownedConnectorOrThrow(
|
|
252
|
+
const row = await ownedConnectorOrThrow(subject, id);
|
|
253
253
|
const config = readConfig(row.config);
|
|
254
254
|
if (config.auth.kind !== 'oauth')
|
|
255
255
|
throw new ConnectorError('that connector is not signed in', 400);
|
|
256
256
|
return saveConfig(row, { ...config, auth: { kind: 'none' }, oauth: true });
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
export async function
|
|
260
|
-
|
|
259
|
+
export async function verifyConnectorForUser(
|
|
260
|
+
subject: string,
|
|
261
261
|
id: string,
|
|
262
262
|
): Promise<ConnectorCheck> {
|
|
263
|
-
const row = await ownedConnectorOrThrow(
|
|
263
|
+
const row = await ownedConnectorOrThrow(subject, id);
|
|
264
264
|
const config = readConfig(row.config);
|
|
265
265
|
try {
|
|
266
266
|
const url = parseConnectorUrl(row.url);
|
|
@@ -275,13 +275,13 @@ export async function verifyConnectorForEmail(
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
export async function
|
|
279
|
-
|
|
278
|
+
export async function renameConnectorForUser(
|
|
279
|
+
subject: string,
|
|
280
280
|
id: string,
|
|
281
281
|
raw: string,
|
|
282
282
|
): Promise<Connector> {
|
|
283
283
|
const name = connectorName(raw);
|
|
284
|
-
const row = await ownedConnectorOrThrow(
|
|
284
|
+
const row = await ownedConnectorOrThrow(subject, id);
|
|
285
285
|
await assertRenameFreeOfClash(row.id, name);
|
|
286
286
|
const rows = await getDb()
|
|
287
287
|
.update(connectors)
|
|
@@ -293,11 +293,11 @@ export async function renameConnectorForEmail(
|
|
|
293
293
|
return toConnector(saved);
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
export async function
|
|
297
|
-
|
|
296
|
+
export async function deleteConnectorForUser(
|
|
297
|
+
subject: string,
|
|
298
298
|
id: string,
|
|
299
299
|
): Promise<DeletedConnector> {
|
|
300
|
-
const row = await ownedConnectorOrThrow(
|
|
300
|
+
const row = await ownedConnectorOrThrow(subject, id);
|
|
301
301
|
const gone = await getDb()
|
|
302
302
|
.delete(connectors)
|
|
303
303
|
.where(eq(connectors.id, row.id))
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
openSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { ApiError } from '../daemon/api-error.js';
|
|
12
|
+
import { ensureSecureDir, writeSecure } from '../daemon/secure-fs.js';
|
|
13
|
+
import {
|
|
14
|
+
AgentAdminError,
|
|
15
|
+
newApiKey,
|
|
16
|
+
normalizeAgentName,
|
|
17
|
+
type AgentSummary,
|
|
18
|
+
type CreatedAgent,
|
|
19
|
+
type DeletedAgent,
|
|
20
|
+
type OwnedAgent,
|
|
21
|
+
type ResetAgentKey,
|
|
22
|
+
} from './agent-admin.js';
|
|
23
|
+
import type { AccountRef } from './account-attach.js';
|
|
24
|
+
import {
|
|
25
|
+
AGENT_FILE,
|
|
26
|
+
agentsDir,
|
|
27
|
+
listAgentFiles,
|
|
28
|
+
readAgentFile,
|
|
29
|
+
type AgentFile,
|
|
30
|
+
} from './file-source.js';
|
|
31
|
+
import { newId } from './ids.js';
|
|
32
|
+
import { registerKey, rotateAgentKey, unregisterAgentKey } from './key-map.js';
|
|
33
|
+
import { MOVABLE_STATIONS } from './materialize.js';
|
|
34
|
+
import type { StationName } from './schema.js';
|
|
35
|
+
import { normalizeAddress } from './users.js';
|
|
36
|
+
|
|
37
|
+
export const LOCAL_PROJECT_ID = 'localdaemon';
|
|
38
|
+
const OWNER_FILE = '.owner';
|
|
39
|
+
|
|
40
|
+
interface Stored {
|
|
41
|
+
path: string;
|
|
42
|
+
file: AgentFile;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const missing = (): AgentAdminError => new AgentAdminError('no such agent', 404);
|
|
46
|
+
|
|
47
|
+
export function localOwner(dir = agentsDir()): string | null {
|
|
48
|
+
try {
|
|
49
|
+
return normalizeAddress(readFileSync(join(dir, OWNER_FILE), 'utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeOwnerOnce(path: string, address: string): boolean {
|
|
56
|
+
let fd: number;
|
|
57
|
+
try {
|
|
58
|
+
fd = openSync(path, 'wx', 0o600);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
writeSync(fd, `${address}\n`);
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function claimLocalOwner(raw: string, dir = agentsDir()): Promise<string> {
|
|
69
|
+
const address = normalizeAddress(raw);
|
|
70
|
+
if (address === null)
|
|
71
|
+
return Promise.reject(new ApiError('an Ethereum address is required', 400));
|
|
72
|
+
ensureSecureDir(dir);
|
|
73
|
+
if (writeOwnerOnce(join(dir, OWNER_FILE), address))
|
|
74
|
+
return Promise.resolve(address);
|
|
75
|
+
if (localOwner(dir) !== address)
|
|
76
|
+
return Promise.reject(
|
|
77
|
+
new ApiError('this machine belongs to another wallet', 403),
|
|
78
|
+
);
|
|
79
|
+
return Promise.resolve(address);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isOwner(subject: string, dir: string): boolean {
|
|
83
|
+
const owner = localOwner(dir);
|
|
84
|
+
return owner !== null && owner === normalizeAddress(subject);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function storedAgents(dir: string): Stored[] {
|
|
88
|
+
return listAgentFiles(dir).map((path) => ({ path, file: readAgentFile(path) }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function save(stored: Stored): void {
|
|
92
|
+
writeSecure(stored.path, `${JSON.stringify(stored.file, null, 2)}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ownedOrThrow(subject: string, id: string, dir: string): Stored {
|
|
96
|
+
if (!isOwner(subject, dir)) throw missing();
|
|
97
|
+
const found = storedAgents(dir).find((s) => s.file.id === id);
|
|
98
|
+
if (found === undefined) throw missing();
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function localOwnedAgentOrThrow(
|
|
103
|
+
subject: string,
|
|
104
|
+
id: string,
|
|
105
|
+
dir = agentsDir(),
|
|
106
|
+
): Promise<{ agent: OwnedAgent }> {
|
|
107
|
+
const { file } = ownedOrThrow(subject, id, dir);
|
|
108
|
+
return Promise.resolve({ agent: { id: file.id, name: file.name } });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function localListAgents(
|
|
112
|
+
subject: string,
|
|
113
|
+
project: string,
|
|
114
|
+
dir = agentsDir(),
|
|
115
|
+
): Promise<AgentSummary[]> {
|
|
116
|
+
if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
|
|
117
|
+
throw new AgentAdminError('no such project', 404);
|
|
118
|
+
return Promise.resolve(
|
|
119
|
+
storedAgents(dir)
|
|
120
|
+
.map(({ file }) => ({ id: file.id, name: file.name, owned: true, key: file.key }))
|
|
121
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function freshId(taken: Set<string>): string {
|
|
126
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
127
|
+
const id = newId();
|
|
128
|
+
if (!taken.has(id)) return id;
|
|
129
|
+
}
|
|
130
|
+
throw new AgentAdminError('could not allocate a free id', 500);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function localCreateAgent(
|
|
134
|
+
subject: string,
|
|
135
|
+
project: string,
|
|
136
|
+
rawName: string,
|
|
137
|
+
dir = agentsDir(),
|
|
138
|
+
): Promise<CreatedAgent> {
|
|
139
|
+
if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
|
|
140
|
+
throw new AgentAdminError('no such project', 404);
|
|
141
|
+
const name = normalizeAgentName(rawName);
|
|
142
|
+
const folder = join(dir, name);
|
|
143
|
+
if (existsSync(join(folder, AGENT_FILE)))
|
|
144
|
+
throw new AgentAdminError(
|
|
145
|
+
`an agent named '${name}' already exists on this machine`,
|
|
146
|
+
409,
|
|
147
|
+
);
|
|
148
|
+
const existing = storedAgents(dir);
|
|
149
|
+
const id = freshId(new Set(existing.map((s) => s.file.id)));
|
|
150
|
+
const key = newApiKey();
|
|
151
|
+
ensureSecureDir(folder);
|
|
152
|
+
save({
|
|
153
|
+
path: join(folder, AGENT_FILE),
|
|
154
|
+
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [] },
|
|
155
|
+
});
|
|
156
|
+
registerKey(key, id);
|
|
157
|
+
return Promise.resolve({ id, name, key });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function localResetAgentKey(
|
|
161
|
+
subject: string,
|
|
162
|
+
id: string,
|
|
163
|
+
dir = agentsDir(),
|
|
164
|
+
): Promise<ResetAgentKey> {
|
|
165
|
+
const stored = ownedOrThrow(subject, id, dir);
|
|
166
|
+
const key = newApiKey();
|
|
167
|
+
stored.file.key = key;
|
|
168
|
+
save(stored);
|
|
169
|
+
rotateAgentKey(id, key);
|
|
170
|
+
return Promise.resolve({ id, name: stored.file.name, key });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function localDeleteAgent(
|
|
174
|
+
subject: string,
|
|
175
|
+
id: string,
|
|
176
|
+
dir = agentsDir(),
|
|
177
|
+
): Promise<DeletedAgent> {
|
|
178
|
+
const stored = ownedOrThrow(subject, id, dir);
|
|
179
|
+
const attached = stored.file.stations.length;
|
|
180
|
+
if (attached > 0)
|
|
181
|
+
throw new AgentAdminError(
|
|
182
|
+
`agent '${stored.file.name}' still has ${String(attached)} station account(s) attached — detach them first`,
|
|
183
|
+
409,
|
|
184
|
+
);
|
|
185
|
+
rmSync(stored.path);
|
|
186
|
+
removeIfEmpty(join(stored.path, '..'));
|
|
187
|
+
unregisterAgentKey(id);
|
|
188
|
+
return Promise.resolve({ id, name: stored.file.name });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeIfEmpty(folder: string): void {
|
|
192
|
+
try {
|
|
193
|
+
rmdirSync(folder);
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertTokenFree(all: Stored[], station: StationName, token: string): void {
|
|
200
|
+
const taken = all.some((s) =>
|
|
201
|
+
s.file.stations.some(
|
|
202
|
+
(a) => a.station === station && a.config.token === token,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
if (taken)
|
|
206
|
+
throw new AgentAdminError(
|
|
207
|
+
'that bot token is already attached to an agent on this machine',
|
|
208
|
+
409,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function localAttachAccount(
|
|
213
|
+
subject: string,
|
|
214
|
+
agentId: string,
|
|
215
|
+
station: StationName,
|
|
216
|
+
config: Record<string, unknown>,
|
|
217
|
+
dir = agentsDir(),
|
|
218
|
+
): Promise<AccountRef> {
|
|
219
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
220
|
+
if (!MOVABLE_STATIONS.has(station))
|
|
221
|
+
throw new AgentAdminError(
|
|
222
|
+
`a ${station} endpoint needs a public url and cannot live on a local daemon`,
|
|
223
|
+
400,
|
|
224
|
+
);
|
|
225
|
+
const token = config.token;
|
|
226
|
+
if (typeof token === 'string') assertTokenFree(storedAgents(dir), station, token);
|
|
227
|
+
const taken = new Set(stored.file.stations.map((a) => a.id));
|
|
228
|
+
const accountId = freshId(taken);
|
|
229
|
+
stored.file.stations.push({ station, id: accountId, allowlist: ['*'], config });
|
|
230
|
+
save(stored);
|
|
231
|
+
return Promise.resolve({ agentId, station, accountId });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function localDetachAccount(
|
|
235
|
+
subject: string,
|
|
236
|
+
agentId: string,
|
|
237
|
+
station: StationName,
|
|
238
|
+
accountId: string,
|
|
239
|
+
dir = agentsDir(),
|
|
240
|
+
): Promise<AccountRef> {
|
|
241
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
242
|
+
const before = stored.file.stations.length;
|
|
243
|
+
stored.file.stations = stored.file.stations.filter(
|
|
244
|
+
(a) => !(a.station === station && a.id === accountId),
|
|
245
|
+
);
|
|
246
|
+
if (stored.file.stations.length === before)
|
|
247
|
+
throw new AgentAdminError('no such account on this agent', 404);
|
|
248
|
+
save(stored);
|
|
249
|
+
return Promise.resolve({ agentId, station, accountId });
|
|
250
|
+
}
|