@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.31
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/api.js +12 -12
- package/dist/bedrock.js +5 -3
- package/dist/cli.js +18 -11
- package/dist/store.js +26 -3
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +9 -14
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +29 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +27 -18
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +21 -19
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +4 -30
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +13 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +8 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +13 -13
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +8 -15
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +6 -20
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +10 -5
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
readJsonBody,
|
|
10
10
|
sendJson,
|
|
11
11
|
type ApiSession,
|
|
12
|
+
type Fence,
|
|
12
13
|
} from './api-http.js';
|
|
13
14
|
import { parseId } from '../db/ids.js';
|
|
14
|
-
import type {
|
|
15
|
+
import type { AgentConnectors } from '../db/agent-connectors.js';
|
|
15
16
|
import type { RelayServerEntry } from './connector-json.js';
|
|
16
17
|
import {
|
|
17
18
|
handleCallback,
|
|
@@ -36,35 +37,8 @@ const asText = (value: unknown): string =>
|
|
|
36
37
|
export interface ConnectorApiDeps extends OAuthRouteDeps {
|
|
37
38
|
listConnectors: (email: string, project: string) => Promise<Connector[]>;
|
|
38
39
|
connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
project: string,
|
|
42
|
-
) => Promise<ConnectorCollectionRow[]>;
|
|
43
|
-
getCollection: (email: string, id: string) => Promise<ConnectorCollectionRow>;
|
|
44
|
-
createCollection: (
|
|
45
|
-
email: string,
|
|
46
|
-
project: string,
|
|
47
|
-
name: string,
|
|
48
|
-
) => Promise<ConnectorCollectionRow>;
|
|
49
|
-
renameCollection: (
|
|
50
|
-
email: string,
|
|
51
|
-
id: string,
|
|
52
|
-
name: string,
|
|
53
|
-
) => Promise<ConnectorCollectionRow>;
|
|
54
|
-
deleteCollection: (
|
|
55
|
-
email: string,
|
|
56
|
-
id: string,
|
|
57
|
-
) => Promise<{ id: string; name: string }>;
|
|
58
|
-
addToCollection: (
|
|
59
|
-
email: string,
|
|
60
|
-
id: string,
|
|
61
|
-
connectorId: string,
|
|
62
|
-
) => Promise<ConnectorCollectionRow>;
|
|
63
|
-
removeFromCollection: (
|
|
64
|
-
email: string,
|
|
65
|
-
id: string,
|
|
66
|
-
connectorId: string,
|
|
67
|
-
) => Promise<ConnectorCollectionRow>;
|
|
40
|
+
agentConnectors: (email: string, agentId: string) => Promise<AgentConnectors>;
|
|
41
|
+
fenceRuntime: Fence;
|
|
68
42
|
createConnector: (
|
|
69
43
|
email: string,
|
|
70
44
|
project: string,
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { RelayTarget } from '../db/connector-relay.js';
|
|
3
|
-
import {
|
|
3
|
+
import { agentIdentity, assertLease, type Fence } from './api-http.js';
|
|
4
4
|
import { ApiError } from './api-error.js';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
|
|
7
7
|
export interface RelayApiDeps {
|
|
8
8
|
target: (
|
|
9
|
-
|
|
9
|
+
agentId: string,
|
|
10
10
|
connectorId: string,
|
|
11
11
|
force: boolean,
|
|
12
12
|
) => Promise<RelayTarget>;
|
|
13
|
+
fence: Fence;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
@@ -196,18 +197,18 @@ type Exchanged =
|
|
|
196
197
|
|
|
197
198
|
async function exchange(
|
|
198
199
|
req: IncomingMessage,
|
|
199
|
-
|
|
200
|
+
agentId: string,
|
|
200
201
|
connectorId: string,
|
|
201
202
|
deps: RelayApiDeps,
|
|
202
203
|
body: Uint8Array<ArrayBuffer> | null,
|
|
203
204
|
signal: AbortSignal,
|
|
204
205
|
): Promise<Exchanged> {
|
|
205
|
-
const target = await deps.target(
|
|
206
|
+
const target = await deps.target(agentId, connectorId, false);
|
|
206
207
|
if (target.kind !== 'ok') return { kind: target.kind };
|
|
207
208
|
let upstream = await forward(req, target, body, signal);
|
|
208
209
|
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
209
210
|
await upstream.body?.cancel();
|
|
210
|
-
const fresh = await deps.target(
|
|
211
|
+
const fresh = await deps.target(agentId, connectorId, true);
|
|
211
212
|
if (fresh.kind !== 'ok') return { kind: 'signin' };
|
|
212
213
|
upstream = await forward(req, fresh, body, signal);
|
|
213
214
|
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
@@ -218,7 +219,7 @@ async function exchange(
|
|
|
218
219
|
async function relayExchange(
|
|
219
220
|
req: IncomingMessage,
|
|
220
221
|
res: ServerResponse,
|
|
221
|
-
|
|
222
|
+
agentId: string,
|
|
222
223
|
connectorId: string,
|
|
223
224
|
deps: RelayApiDeps,
|
|
224
225
|
): Promise<void> {
|
|
@@ -234,7 +235,7 @@ async function relayExchange(
|
|
|
234
235
|
const body = req.method === 'POST' ? await readCapped(req) : null;
|
|
235
236
|
const out = await exchange(
|
|
236
237
|
req,
|
|
237
|
-
|
|
238
|
+
agentId,
|
|
238
239
|
connectorId,
|
|
239
240
|
deps,
|
|
240
241
|
body,
|
|
@@ -262,13 +263,14 @@ function dispatch(
|
|
|
262
263
|
connectorId: string,
|
|
263
264
|
deps: RelayApiDeps,
|
|
264
265
|
): void {
|
|
265
|
-
const who =
|
|
266
|
+
const who = agentIdentity(req);
|
|
266
267
|
if (who === null) {
|
|
267
268
|
answer(res, 401, { error: 'unauthorized' });
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
270
|
-
|
|
271
|
-
(
|
|
271
|
+
assertLease(who, deps.fence)
|
|
272
|
+
.then(() => relayExchange(req, res, who.agentId, connectorId, deps))
|
|
273
|
+
.catch((err: unknown) => {
|
|
272
274
|
if (err instanceof ApiError) {
|
|
273
275
|
answer(res, err.status, { error: err.message });
|
|
274
276
|
} else {
|
|
@@ -277,8 +279,7 @@ function dispatch(
|
|
|
277
279
|
if (!res.writableEnded) res.end();
|
|
278
280
|
}
|
|
279
281
|
closeIfBodyUnread(req, res);
|
|
280
|
-
}
|
|
281
|
-
);
|
|
282
|
+
});
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
export function handleRelayRequest(
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
runIdentity,
|
|
9
9
|
sendJson,
|
|
10
10
|
} from './api-http.js';
|
|
11
|
-
import {
|
|
11
|
+
import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
|
|
12
12
|
import { sessionTtlFromEnv } from './google-oauth.js';
|
|
13
13
|
import { signRunToken } from './session.js';
|
|
14
14
|
import type { LoadedAgent } from '../db/materialize.js';
|
|
@@ -27,6 +27,7 @@ export interface RunApiDeps {
|
|
|
27
27
|
fenceRuntime: (runtimeId: string, agentId: string) => Promise<void>;
|
|
28
28
|
touchRuntime: (runtimeId: string) => Promise<void>;
|
|
29
29
|
loadAgent: (agentId: string) => Promise<LoadedAgent>;
|
|
30
|
+
blockedStations: (agentId: string) => Promise<string[]>;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function secretOrNull(): string | null {
|
|
@@ -53,20 +54,28 @@ async function handleClaim(
|
|
|
53
54
|
const body = await readJsonBody(req);
|
|
54
55
|
const raw = bodyField(body, 'code');
|
|
55
56
|
const code = typeof raw === 'string' ? raw.trim() : '';
|
|
56
|
-
if (!
|
|
57
|
+
if (!AGENT_CODE_RE.test(code)) {
|
|
57
58
|
sendJson(req, res, 400, {
|
|
58
59
|
error:
|
|
59
|
-
'that does not look like
|
|
60
|
+
'that does not look like an agent code — metro start wants the ma_… code from the agent page',
|
|
60
61
|
});
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
|
-
const taken =
|
|
64
|
+
const taken = takeAgentCode(code);
|
|
64
65
|
if (taken === undefined) {
|
|
65
66
|
sendJson(req, res, 400, {
|
|
66
67
|
error: 'that code has expired or was already used',
|
|
67
68
|
});
|
|
68
69
|
return;
|
|
69
70
|
}
|
|
71
|
+
const blocked = await deps.blockedStations(taken.agentId);
|
|
72
|
+
if (blocked.length > 0) {
|
|
73
|
+
sendJson(req, res, 409, {
|
|
74
|
+
error:
|
|
75
|
+
`this agent holds ${blocked.join(', ')}, which only runs on metro because a webhook url has to be publicly reachable. Detach it, or move it to a second agent that stays on metro, then start this one.`,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
70
79
|
const lease = await deps.claimRuntime(taken.agentId, labelOf(body));
|
|
71
80
|
const token = signRunToken(
|
|
72
81
|
{ email: taken.email, agentId: lease.agentId, runtimeId: lease.runtimeId },
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
handleConnectorApiRequest,
|
|
6
6
|
type ConnectorApiDeps,
|
|
7
7
|
} from './connector-api.js';
|
|
8
|
-
import { handleCollectionApiRequest } from './collection-api.js';
|
|
9
8
|
import { handleCliPairRequest } from './cli-pair-api.js';
|
|
10
9
|
import { handleRunApiRequest, type RunApiDeps } from './run-api.js';
|
|
11
10
|
import type { RelayApiDeps } from './relay.js';
|
|
@@ -13,9 +12,14 @@ import {
|
|
|
13
12
|
handleProjectApiRequest,
|
|
14
13
|
type ProjectApiDeps,
|
|
15
14
|
} from './project-api.js';
|
|
15
|
+
import {
|
|
16
|
+
handleAgentConnectorRequest,
|
|
17
|
+
type AgentConnectorApiDeps,
|
|
18
|
+
} from './agent-connector-api.js';
|
|
16
19
|
|
|
17
20
|
export interface SessionApis {
|
|
18
21
|
agentApi?: AgentApiDeps;
|
|
22
|
+
agentConnectorApi?: AgentConnectorApiDeps;
|
|
19
23
|
connectorApi?: ConnectorApiDeps;
|
|
20
24
|
projectApi?: ProjectApiDeps;
|
|
21
25
|
runApi?: RunApiDeps;
|
|
@@ -27,7 +31,7 @@ export function handleSessionApis(
|
|
|
27
31
|
res: ServerResponse,
|
|
28
32
|
apis: SessionApis,
|
|
29
33
|
): boolean {
|
|
30
|
-
const { agentApi, connectorApi, projectApi, runApi } = apis;
|
|
34
|
+
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi } = apis;
|
|
31
35
|
const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
|
|
32
36
|
if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
|
|
33
37
|
if (projectApi)
|
|
@@ -35,9 +39,10 @@ export function handleSessionApis(
|
|
|
35
39
|
if (connectorApi)
|
|
36
40
|
routes.push(
|
|
37
41
|
() => handleCliPairRequest(req, res, connectorApi),
|
|
38
|
-
() => handleCollectionApiRequest(req, res, connectorApi),
|
|
39
42
|
() => handleConnectorApiRequest(req, res, connectorApi),
|
|
40
43
|
);
|
|
44
|
+
if (agentConnectorApi)
|
|
45
|
+
routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
|
|
41
46
|
if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
|
|
42
47
|
return routes.some((run) => run());
|
|
43
48
|
}
|
|
@@ -99,9 +99,9 @@ interface SessionClaims {
|
|
|
99
99
|
agentIds: string[];
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export interface
|
|
102
|
+
export interface AgentClaims {
|
|
103
103
|
email: string;
|
|
104
|
-
|
|
104
|
+
agentId: string;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface RunClaims {
|
|
@@ -147,17 +147,17 @@ export function verifySession(
|
|
|
147
147
|
return { email: p.sub, agentIds: ids as string[] };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
export function
|
|
151
|
-
claims:
|
|
150
|
+
export function signAgentToken(
|
|
151
|
+
claims: AgentClaims,
|
|
152
152
|
secret: string,
|
|
153
153
|
opts: { ttlSec?: number; now?: number } = {},
|
|
154
154
|
): string {
|
|
155
155
|
const iat = nowSec(opts.now);
|
|
156
156
|
return sign(
|
|
157
157
|
{
|
|
158
|
-
typ: '
|
|
158
|
+
typ: 'agent',
|
|
159
159
|
sub: claims.email,
|
|
160
|
-
|
|
160
|
+
agent: claims.agentId,
|
|
161
161
|
iat,
|
|
162
162
|
exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
|
|
163
163
|
},
|
|
@@ -165,18 +165,18 @@ export function signCliToken(
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
export function
|
|
168
|
+
export function verifyAgentToken(
|
|
169
169
|
token: string,
|
|
170
170
|
secret: string,
|
|
171
171
|
now?: number,
|
|
172
|
-
):
|
|
172
|
+
): AgentClaims {
|
|
173
173
|
const p = verify(token, secret);
|
|
174
|
-
if (p.typ !== '
|
|
174
|
+
if (p.typ !== 'agent') throw new SessionError('wrong token type');
|
|
175
175
|
if (typeof p.exp !== 'number' || p.exp < nowSec(now))
|
|
176
|
-
throw new SessionError('
|
|
177
|
-
if (typeof p.sub !== 'string' || typeof p.
|
|
178
|
-
throw new SessionError('malformed
|
|
179
|
-
return { email: p.sub,
|
|
176
|
+
throw new SessionError('agent token expired');
|
|
177
|
+
if (typeof p.sub !== 'string' || typeof p.agent !== 'string')
|
|
178
|
+
throw new SessionError('malformed agent token');
|
|
179
|
+
return { email: p.sub, agentId: p.agent };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export function signRunToken(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, ne } from 'drizzle-orm';
|
|
2
|
+
import { getDb } from './client.js';
|
|
3
|
+
import { newId } from './ids.js';
|
|
4
|
+
import { isUniqueViolation } from './users.js';
|
|
5
|
+
import { ownedAgentOrThrow } from './agent-admin.js';
|
|
6
|
+
import { ConnectorError } from './connector-config.js';
|
|
7
|
+
import { agentConnectors, agents, connectors } from './schema.js';
|
|
8
|
+
|
|
9
|
+
export interface AgentConnectors {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
connectorIds: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const nameClash = (name: string, agent: string): ConnectorError =>
|
|
16
|
+
new ConnectorError(
|
|
17
|
+
`the agent '${agent}' already has a connector named '${name}'`,
|
|
18
|
+
409,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
async function collidingAgent(
|
|
22
|
+
agentIds: string[],
|
|
23
|
+
name: string,
|
|
24
|
+
exceptConnectorId: string,
|
|
25
|
+
): Promise<string | null> {
|
|
26
|
+
if (agentIds.length === 0) return null;
|
|
27
|
+
const rows = await getDb()
|
|
28
|
+
.select({ agent: agents.name })
|
|
29
|
+
.from(agentConnectors)
|
|
30
|
+
.innerJoin(connectors, eq(connectors.id, agentConnectors.connectorId))
|
|
31
|
+
.innerJoin(agents, eq(agents.id, agentConnectors.agentId))
|
|
32
|
+
.where(
|
|
33
|
+
and(
|
|
34
|
+
inArray(agentConnectors.agentId, agentIds),
|
|
35
|
+
eq(connectors.name, name),
|
|
36
|
+
ne(connectors.id, exceptConnectorId),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
.limit(1);
|
|
40
|
+
return rows[0]?.agent ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function assertRenameFreeOfClash(
|
|
44
|
+
connectorId: string,
|
|
45
|
+
name: string,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const rows = await getDb()
|
|
48
|
+
.select({ agentId: agentConnectors.agentId })
|
|
49
|
+
.from(agentConnectors)
|
|
50
|
+
.where(eq(agentConnectors.connectorId, connectorId));
|
|
51
|
+
const clash = await collidingAgent(
|
|
52
|
+
rows.map((r) => r.agentId),
|
|
53
|
+
name,
|
|
54
|
+
connectorId,
|
|
55
|
+
);
|
|
56
|
+
if (clash !== null) throw nameClash(name, clash);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function connectorIdsOfAgent(agentId: string): Promise<string[]> {
|
|
60
|
+
const rows = await getDb()
|
|
61
|
+
.select({ connectorId: agentConnectors.connectorId })
|
|
62
|
+
.from(agentConnectors)
|
|
63
|
+
.where(eq(agentConnectors.agentId, agentId))
|
|
64
|
+
.orderBy(asc(agentConnectors.id));
|
|
65
|
+
return rows.map((r) => r.connectorId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function connectorIdsByAgent(
|
|
69
|
+
agentIds: string[],
|
|
70
|
+
): Promise<Map<string, string[]>> {
|
|
71
|
+
const out = new Map<string, string[]>();
|
|
72
|
+
if (agentIds.length === 0) return out;
|
|
73
|
+
const rows = await getDb()
|
|
74
|
+
.select({
|
|
75
|
+
agentId: agentConnectors.agentId,
|
|
76
|
+
connectorId: agentConnectors.connectorId,
|
|
77
|
+
})
|
|
78
|
+
.from(agentConnectors)
|
|
79
|
+
.where(inArray(agentConnectors.agentId, agentIds))
|
|
80
|
+
.orderBy(asc(agentConnectors.id));
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const list = out.get(row.agentId) ?? [];
|
|
83
|
+
list.push(row.connectorId);
|
|
84
|
+
out.set(row.agentId, list);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function agentConnectorsForEmail(
|
|
90
|
+
email: string,
|
|
91
|
+
agentId: string,
|
|
92
|
+
): Promise<AgentConnectors> {
|
|
93
|
+
const { agent } = await ownedAgentOrThrow(email, agentId);
|
|
94
|
+
return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function connectorNameIn(
|
|
98
|
+
projectId: string,
|
|
99
|
+
connectorId: string,
|
|
100
|
+
): Promise<string | null> {
|
|
101
|
+
const rows = await getDb()
|
|
102
|
+
.select({ name: connectors.name })
|
|
103
|
+
.from(connectors)
|
|
104
|
+
.where(and(eq(connectors.id, connectorId), eq(connectors.projectId, projectId)));
|
|
105
|
+
return rows[0]?.name ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function addConnectorToAgentForEmail(
|
|
109
|
+
email: string,
|
|
110
|
+
agentId: string,
|
|
111
|
+
connectorId: string,
|
|
112
|
+
): Promise<AgentConnectors> {
|
|
113
|
+
const { agent, projectId } = await ownedAgentOrThrow(email, agentId);
|
|
114
|
+
const name = await connectorNameIn(projectId, connectorId);
|
|
115
|
+
if (name === null) throw new ConnectorError('no such connector', 404);
|
|
116
|
+
const clash = await collidingAgent([agent.id], name, connectorId);
|
|
117
|
+
if (clash !== null) throw nameClash(name, clash);
|
|
118
|
+
try {
|
|
119
|
+
await getDb()
|
|
120
|
+
.insert(agentConnectors)
|
|
121
|
+
.values({ id: newId(), agentId: agent.id, connectorId });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (!isUniqueViolation(err)) throw err;
|
|
124
|
+
}
|
|
125
|
+
return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function removeConnectorFromAgentForEmail(
|
|
129
|
+
email: string,
|
|
130
|
+
agentId: string,
|
|
131
|
+
connectorId: string,
|
|
132
|
+
): Promise<AgentConnectors> {
|
|
133
|
+
const { agent } = await ownedAgentOrThrow(email, agentId);
|
|
134
|
+
await getDb()
|
|
135
|
+
.delete(agentConnectors)
|
|
136
|
+
.where(
|
|
137
|
+
and(
|
|
138
|
+
eq(agentConnectors.agentId, agent.id),
|
|
139
|
+
eq(agentConnectors.connectorId, connectorId),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
return { ...agent, connectorIds: await connectorIdsOfAgent(agent.id) };
|
|
143
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { errMsg, log } from '../daemon/log.js';
|
|
9
9
|
import { readConfig, type ConnectorConfig } from './connector-config.js';
|
|
10
10
|
import { getDb } from './client.js';
|
|
11
|
-
import {
|
|
11
|
+
import { agentConnectors, connectors } from './schema.js';
|
|
12
12
|
|
|
13
13
|
export type RelayTarget =
|
|
14
14
|
| { kind: 'ok'; url: string; headers: Record<string, string> }
|
|
@@ -32,17 +32,17 @@ export function headerAuthHeaders(
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
async function memberRow(
|
|
35
|
-
|
|
35
|
+
agentId: string,
|
|
36
36
|
connectorId: string,
|
|
37
37
|
): Promise<ConnectorRow | undefined> {
|
|
38
38
|
const rows = await getDb()
|
|
39
39
|
.select({ connector: connectors })
|
|
40
|
-
.from(
|
|
41
|
-
.innerJoin(connectors, eq(
|
|
40
|
+
.from(agentConnectors)
|
|
41
|
+
.innerJoin(connectors, eq(agentConnectors.connectorId, connectors.id))
|
|
42
42
|
.where(
|
|
43
43
|
and(
|
|
44
|
-
eq(
|
|
45
|
-
eq(
|
|
44
|
+
eq(agentConnectors.agentId, agentId),
|
|
45
|
+
eq(agentConnectors.connectorId, connectorId),
|
|
46
46
|
),
|
|
47
47
|
);
|
|
48
48
|
return rows[0]?.connector;
|
|
@@ -112,11 +112,11 @@ export function fixedTarget(
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export async function relayTarget(
|
|
115
|
-
|
|
115
|
+
agentId: string,
|
|
116
116
|
connectorId: string,
|
|
117
117
|
force: boolean,
|
|
118
118
|
): Promise<RelayTarget> {
|
|
119
|
-
const row = await memberRow(
|
|
119
|
+
const row = await memberRow(agentId, connectorId);
|
|
120
120
|
if (row === undefined) return { kind: 'missing' };
|
|
121
121
|
parseConnectorUrl(row.url);
|
|
122
122
|
const config = readConfig(row.config);
|
|
@@ -188,7 +188,7 @@ export async function deleteProjectForEmail(
|
|
|
188
188
|
await getDb().delete(projects).where(eq(projects.id, access.projectId));
|
|
189
189
|
} catch {
|
|
190
190
|
throw new ProjectError(
|
|
191
|
-
'this project still holds agents
|
|
191
|
+
'this project still holds agents or connectors',
|
|
192
192
|
409,
|
|
193
193
|
);
|
|
194
194
|
}
|
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
import { ownedAgentOrThrow } from './agent-admin.js';
|
|
2
|
-
import {
|
|
3
|
-
import { mintRunCode } from '../daemon/run-pair.js';
|
|
2
|
+
import { mintAgentCode } from '../daemon/agent-pair.js';
|
|
4
3
|
import { loadAllStationsFor, unmovableStations } from './materialize.js';
|
|
5
4
|
import { releaseRuntime } from './runtimes.js';
|
|
6
5
|
|
|
7
|
-
export async function
|
|
6
|
+
export async function mintAgentCodeForEmail(
|
|
8
7
|
email: string,
|
|
9
8
|
agentId: string,
|
|
10
|
-
): Promise<{ code: string; expiresAt: number }> {
|
|
9
|
+
): Promise<{ code: string; expiresAt: number; agent: string }> {
|
|
11
10
|
const { agent } = await ownedAgentOrThrow(email, agentId);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'would arrive at metro while this agent listens on your machine, so ' +
|
|
18
|
-
'they would be dropped. Detach it, or attach it to a second agent that ' +
|
|
19
|
-
'stays on metro, then authorize this one.',
|
|
20
|
-
409,
|
|
21
|
-
);
|
|
22
|
-
return mintRunCode({ email, agentId: agent.id });
|
|
11
|
+
return { ...mintAgentCode({ email, agentId: agent.id }), agent: agent.name };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function blockedStationsFor(agentId: string): Promise<string[]> {
|
|
15
|
+
return unmovableStations(await loadAllStationsFor(agentId));
|
|
23
16
|
}
|
|
24
17
|
|
|
25
18
|
export async function releaseRuntimeForEmail(
|
|
@@ -97,34 +97,20 @@ export const connectors = pgTable(
|
|
|
97
97
|
},
|
|
98
98
|
);
|
|
99
99
|
|
|
100
|
-
export const
|
|
101
|
-
'
|
|
100
|
+
export const agentConnectors = pgTable(
|
|
101
|
+
'agent_connectors',
|
|
102
102
|
{
|
|
103
103
|
id: text('id').primaryKey(),
|
|
104
|
-
|
|
105
|
-
.notNull()
|
|
106
|
-
.references(() => projects.id, { onDelete: 'restrict' }),
|
|
107
|
-
name: text('name').notNull(),
|
|
108
|
-
},
|
|
109
|
-
(t) => [
|
|
110
|
-
unique('collections_project_id_name_unique').on(t.projectId, t.name),
|
|
111
|
-
],
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
export const collectionItems = pgTable(
|
|
115
|
-
'collection_items',
|
|
116
|
-
{
|
|
117
|
-
id: text('id').primaryKey(),
|
|
118
|
-
collectionId: text('collection_id')
|
|
104
|
+
agentId: text('agent_id')
|
|
119
105
|
.notNull()
|
|
120
|
-
.references(() =>
|
|
106
|
+
.references(() => agents.id, { onDelete: 'cascade' }),
|
|
121
107
|
connectorId: text('connector_id')
|
|
122
108
|
.notNull()
|
|
123
109
|
.references(() => connectors.id, { onDelete: 'cascade' }),
|
|
124
110
|
},
|
|
125
111
|
(t) => [
|
|
126
|
-
unique('
|
|
127
|
-
t.
|
|
112
|
+
unique('agent_connectors_agent_id_connector_id_unique').on(
|
|
113
|
+
t.agentId,
|
|
128
114
|
t.connectorId,
|
|
129
115
|
),
|
|
130
116
|
],
|
|
@@ -8,12 +8,17 @@ const UNIQUE_VIOLATION = '23505';
|
|
|
8
8
|
|
|
9
9
|
export class UserError extends ApiError {}
|
|
10
10
|
|
|
11
|
+
const CAUSE_DEPTH = 5;
|
|
12
|
+
|
|
11
13
|
export function isUniqueViolation(err: unknown): boolean {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
let current: unknown = err;
|
|
15
|
+
for (let depth = 0; depth < CAUSE_DEPTH; depth += 1) {
|
|
16
|
+
if (typeof current !== 'object' || current === null) return false;
|
|
17
|
+
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
|
18
|
+
if (code === UNIQUE_VIOLATION) return true;
|
|
19
|
+
current = cause;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export function normalizeEmail(raw: string): string {
|
package/runtime/runtime.json
CHANGED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { makeCodeStore } from './pair-codes.js';
|
|
2
|
-
|
|
3
|
-
export interface CliCode {
|
|
4
|
-
email: string;
|
|
5
|
-
collectionId: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const store = makeCodeStore<CliCode>('mc');
|
|
9
|
-
|
|
10
|
-
export const CLI_CODE_RE = store.pattern;
|
|
11
|
-
|
|
12
|
-
export const mintCliCode = (
|
|
13
|
-
entry: CliCode,
|
|
14
|
-
now?: number,
|
|
15
|
-
): { code: string; expiresAt: number } => store.mint(entry, now);
|
|
16
|
-
|
|
17
|
-
export const takeCliCode = (code: string, now?: number): CliCode | undefined =>
|
|
18
|
-
store.take(code, now);
|
|
19
|
-
|
|
20
|
-
export const cliCodeCount = (): number => store.count();
|