@stage-labs/metro 0.1.0-beta.44 → 0.1.0-beta.46
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 +5 -5
- package/dist/bedrock-proxy.js +1 -1
- package/dist/cli.js +6 -6
- package/dist/plugin.js +1 -1
- package/dist/prompt.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/serve.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-grant.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +5 -23
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-files.ts +1 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +2 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-tools.ts +3 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-verify.ts +1 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/events.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +4 -83
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/is-record.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-cli-api.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +1 -37
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-client.ts +1 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-discovery.ts +1 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +0 -33
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/protocol.ts +3 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +1 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +1 -42
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +2 -11
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +8 -90
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/version.ts +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-config.ts +1 -4
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +6 -4
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +1 -3
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +11 -22
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +3 -13
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +3 -3
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -4
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/event-store.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/stations/account-store.ts +1 -21
- package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-resolve.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/stations/lines.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram/package.json +0 -2
- package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/webhook/src/station.ts +2 -5
- package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +0 -1
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/token-carry.ts +0 -45
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConnectorVerifyError, parseConnectorUrl } from './connector-verify.js';
|
|
2
|
+
import { isRecord } from './is-record.js';
|
|
2
3
|
|
|
3
4
|
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
4
5
|
|
|
@@ -14,10 +15,6 @@ function refused(message: string): ConnectorVerifyError {
|
|
|
14
15
|
return new ConnectorVerifyError(message, 400);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
18
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
18
|
const str = (value: unknown): string =>
|
|
22
19
|
typeof value === 'string' ? value : '';
|
|
23
20
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import {
|
|
3
3
|
closeSync,
|
|
4
|
-
existsSync,
|
|
5
4
|
mkdirSync,
|
|
6
5
|
openSync,
|
|
7
6
|
readFileSync,
|
|
@@ -20,10 +19,6 @@ mkdirSync(STATE_DIR, { recursive: true });
|
|
|
20
19
|
export const configDir = (): string =>
|
|
21
20
|
process.env.METRO_CONFIG_DIR ??
|
|
22
21
|
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'metro');
|
|
23
|
-
export const CONFIG_DIR = configDir();
|
|
24
|
-
export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
|
|
25
|
-
|
|
26
|
-
export const TRAINS_ENV_FILE = join(homedir(), '.metro', '.env');
|
|
27
22
|
|
|
28
23
|
export function trainsDir(): string {
|
|
29
24
|
const env = process.env.METRO_TRAINS_DIR?.trim();
|
|
@@ -34,34 +29,6 @@ export function trainsDir(): string {
|
|
|
34
29
|
export const isLocalMode = (): boolean =>
|
|
35
30
|
process.env.METRO_MODE?.trim() === 'local';
|
|
36
31
|
|
|
37
|
-
export const envSources = (): readonly string[] => [
|
|
38
|
-
join(process.cwd(), '.env'),
|
|
39
|
-
TRAINS_ENV_FILE,
|
|
40
|
-
CONFIG_ENV_FILE,
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
|
|
44
|
-
const QUOTED_RE = /^(['"])(.*)\1$/;
|
|
45
|
-
|
|
46
|
-
export function readDotenv(path: string): Record<string, string> {
|
|
47
|
-
if (!existsSync(path)) return {};
|
|
48
|
-
const out: Record<string, string> = {};
|
|
49
|
-
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
50
|
-
const m = LINE_RE.exec(line);
|
|
51
|
-
if (m?.[1] !== undefined && m[2] !== undefined)
|
|
52
|
-
out[m[1]] = m[2].replace(QUOTED_RE, '$2');
|
|
53
|
-
}
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function loadMetroEnv(): void {
|
|
58
|
-
for (const path of envSources()) {
|
|
59
|
-
for (const [k, v] of Object.entries(readDotenv(path))) {
|
|
60
|
-
process.env[k] ??= v;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
32
|
function readLockPid(lockFile: string): number {
|
|
66
33
|
try {
|
|
67
34
|
return Number(readFileSync(lockFile, 'utf8').trim());
|
|
@@ -11,7 +11,7 @@ export {
|
|
|
11
11
|
type TrainErrorInfo,
|
|
12
12
|
} from './train-error.js';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const CALL_TIMEOUT_MS = 60_000;
|
|
15
15
|
|
|
16
16
|
export interface Pending {
|
|
17
17
|
resolve: (r: TrainCallResponse) => void;
|
|
@@ -24,7 +24,7 @@ export interface CallTarget {
|
|
|
24
24
|
proc: ({ stdin?: unknown } & Record<string, unknown>) | null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const STDOUT_LINE_MAX = 4 * 1024 * 1024;
|
|
28
28
|
|
|
29
29
|
export type TrainEvent = {
|
|
30
30
|
station?: string;
|
|
@@ -149,7 +149,7 @@ export function readCalls(
|
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
function encodeCall(id: string, action: string, args: unknown): string {
|
|
153
153
|
return JSON.stringify({ op: 'call', id, action, args }) + '\n';
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
sendJson,
|
|
10
10
|
} from './api-http.js';
|
|
11
11
|
import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
|
|
12
|
-
import {
|
|
12
|
+
import { SESSION_TTL_SEC } from './session-config.js';
|
|
13
13
|
import { signRunToken } from './session.js';
|
|
14
14
|
import type { LoadedAgent } from '../db/materialize.js';
|
|
15
15
|
import type { RuntimeLease } from '../db/runtimes.js';
|
|
@@ -80,7 +80,7 @@ async function handleClaim(
|
|
|
80
80
|
const token = signRunToken(
|
|
81
81
|
{ subject: taken.subject, agentId: lease.agentId, runtimeId: lease.runtimeId },
|
|
82
82
|
secret,
|
|
83
|
-
{ ttlSec:
|
|
83
|
+
{ ttlSec: SESSION_TTL_SEC },
|
|
84
84
|
);
|
|
85
85
|
log.info(
|
|
86
86
|
{ agent: lease.agentId, runtime: lease.runtimeId, label: lease.label },
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
const
|
|
1
|
+
export const SESSION_TTL_SEC = 30 * 24 * 3600;
|
|
2
2
|
const PREVIEW = /^([a-z0-9-]+--)?metro-ui\.netlify\.app$/;
|
|
3
3
|
|
|
4
|
-
export function sessionTtlFromEnv(): number {
|
|
5
|
-
const ttl = Number(process.env.METRO_SESSION_TTL_SEC);
|
|
6
|
-
return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SEC;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
4
|
export function allowedWebHost(host: string): boolean {
|
|
10
5
|
if (host === 'metro.box' || host === 'localhost' || host === '127.0.0.1')
|
|
11
6
|
return true;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHmac,
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { ID_RE } from '../db/ids.js';
|
|
3
3
|
|
|
4
4
|
export class SessionError extends Error {}
|
|
@@ -53,47 +53,6 @@ function verify(token: string, secret: string): Record<string, unknown> {
|
|
|
53
53
|
|
|
54
54
|
const nowSec = (now?: number): number => Math.floor((now ?? Date.now()) / 1000);
|
|
55
55
|
|
|
56
|
-
export function newNonce(): string {
|
|
57
|
-
return b64url(randomBytes(16));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface StateClaims {
|
|
61
|
-
return_to: string;
|
|
62
|
-
nonce: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function signState(
|
|
66
|
-
claims: StateClaims,
|
|
67
|
-
secret: string,
|
|
68
|
-
opts: { ttlSec?: number; now?: number } = {},
|
|
69
|
-
): string {
|
|
70
|
-
const iat = nowSec(opts.now);
|
|
71
|
-
return sign(
|
|
72
|
-
{
|
|
73
|
-
typ: 'state',
|
|
74
|
-
return_to: claims.return_to,
|
|
75
|
-
nonce: claims.nonce,
|
|
76
|
-
iat,
|
|
77
|
-
exp: iat + (opts.ttlSec ?? 600),
|
|
78
|
-
},
|
|
79
|
-
secret,
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function verifyState(
|
|
84
|
-
token: string,
|
|
85
|
-
secret: string,
|
|
86
|
-
now?: number,
|
|
87
|
-
): StateClaims {
|
|
88
|
-
const p = verify(token, secret);
|
|
89
|
-
if (p.typ !== 'state') throw new SessionError('wrong token type');
|
|
90
|
-
if (typeof p.exp !== 'number' || p.exp < nowSec(now))
|
|
91
|
-
throw new SessionError('state expired');
|
|
92
|
-
if (typeof p.return_to !== 'string' || typeof p.nonce !== 'string')
|
|
93
|
-
throw new SessionError('malformed state');
|
|
94
|
-
return { return_to: p.return_to, nonce: p.nonce };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
56
|
interface SessionClaims {
|
|
98
57
|
subject: string;
|
|
99
58
|
agentIds: string[];
|
|
@@ -10,16 +10,8 @@ export interface SiweLogin {
|
|
|
10
10
|
signature: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export interface SiweVerifyArgs {
|
|
14
|
-
address: `0x${string}`;
|
|
15
|
-
message: string;
|
|
16
|
-
signature: Hex;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
13
|
export interface SiweDeps {
|
|
20
14
|
takeNonce: (nonce: string) => boolean;
|
|
21
|
-
now?: number;
|
|
22
|
-
verify?: (args: SiweVerifyArgs) => Promise<boolean>;
|
|
23
15
|
}
|
|
24
16
|
|
|
25
17
|
const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
|
|
@@ -66,14 +58,13 @@ export async function verifySiweLogin(
|
|
|
66
58
|
if (!HEX_SIGNATURE.test(input.signature))
|
|
67
59
|
throw new SiweError('that is not an Ethereum signature', 400);
|
|
68
60
|
const { address, domain, nonce, parsed } = fieldsOrThrow(input.message);
|
|
69
|
-
const time = new Date(
|
|
61
|
+
const time = new Date();
|
|
70
62
|
if (!validateSiweMessage({ message: parsed, domain, nonce, time }))
|
|
71
63
|
throw new SiweError('this sign-in message is expired or does not match', 400);
|
|
72
64
|
if (!deps.takeNonce(nonce))
|
|
73
65
|
throw new SiweError('that sign-in has expired or was already used', 400);
|
|
74
|
-
const verify = deps.verify ?? verifyMessage;
|
|
75
66
|
const signature = input.signature as Hex;
|
|
76
|
-
if (!(await
|
|
67
|
+
if (!(await verifyMessage({ address, message: input.message, signature })))
|
|
77
68
|
throw new SiweError(
|
|
78
69
|
'the signature does not match the address; smart-contract wallets are not supported yet',
|
|
79
70
|
401,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { mintNonce, takeNonce } from './siwe-nonces.js';
|
|
11
11
|
import { verifySiweLogin } from './siwe-auth.js';
|
|
12
12
|
import { signSession } from './session.js';
|
|
13
|
-
import {
|
|
13
|
+
import { SESSION_TTL_SEC } from './session-config.js';
|
|
14
14
|
import { ensureUserWithProject } from '../db/projects.js';
|
|
15
15
|
|
|
16
16
|
const NONCE_PATH = '/auth/siwe/nonce';
|
|
@@ -39,7 +39,7 @@ async function handleVerify(
|
|
|
39
39
|
const userId = await deps.ensureUser(address);
|
|
40
40
|
log.info({ userId, address }, 'siwe: session issued');
|
|
41
41
|
const session = signSession({ subject: address, agentIds: [] }, secret, {
|
|
42
|
-
ttlSec:
|
|
42
|
+
ttlSec: SESSION_TTL_SEC,
|
|
43
43
|
});
|
|
44
44
|
sendJson(req, res, 200, { session, address });
|
|
45
45
|
}
|
|
@@ -1,31 +1,15 @@
|
|
|
1
|
-
import { spawn,
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
2
|
import { timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { Resolver } from 'node:dns/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { STATE_DIR } from './paths.js';
|
|
7
6
|
import { errMsg, log } from './log.js';
|
|
8
7
|
import { readJson } from './secure-fs.js';
|
|
9
8
|
|
|
10
|
-
const FILE = join(STATE_DIR, 'tunnel.json');
|
|
11
|
-
const LEGACY_WEBHOOKS_FILE = join(STATE_DIR, 'webhooks.json');
|
|
12
9
|
const RESTART_DELAY_MS = 2_000;
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
hostname: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface QuickTunnel {
|
|
20
|
-
quick: true;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type TunnelConfig = NamedTunnel | QuickTunnel;
|
|
24
|
-
|
|
25
|
-
const isQuick = (cfg: TunnelConfig): cfg is QuickTunnel => 'quick' in cfg;
|
|
26
|
-
|
|
27
|
-
export const tunnelConfigFromEnv = (): TunnelConfig | null =>
|
|
28
|
-
process.env.METRO_TUNNEL?.trim() === 'quick' ? { quick: true } : null;
|
|
11
|
+
export const quickTunnelWanted = (): boolean =>
|
|
12
|
+
process.env.METRO_TUNNEL?.trim() === 'quick';
|
|
29
13
|
|
|
30
14
|
const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
31
15
|
|
|
@@ -61,17 +45,11 @@ async function untilResolvable(host: string, resolves: Resolves): Promise<boolea
|
|
|
61
45
|
return false;
|
|
62
46
|
}
|
|
63
47
|
|
|
64
|
-
export function configuredTunnelHost(): string | null {
|
|
65
|
-
const cfg = loadTunnelConfig();
|
|
66
|
-
return cfg !== null && !isQuick(cfg) ? cfg.hostname : null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
48
|
export interface Endpoint {
|
|
70
49
|
id: string;
|
|
71
50
|
webhookId?: string;
|
|
72
51
|
label: string;
|
|
73
52
|
secret?: string;
|
|
74
|
-
session?: string;
|
|
75
53
|
createdAt: string;
|
|
76
54
|
}
|
|
77
55
|
interface AccountRecord {
|
|
@@ -79,7 +57,6 @@ interface AccountRecord {
|
|
|
79
57
|
webhookId?: unknown;
|
|
80
58
|
label?: unknown;
|
|
81
59
|
secret?: unknown;
|
|
82
|
-
session?: unknown;
|
|
83
60
|
createdAt?: unknown;
|
|
84
61
|
}
|
|
85
62
|
|
|
@@ -101,7 +78,6 @@ function toEndpoint(raw: AccountRecord): Endpoint | null {
|
|
|
101
78
|
webhookId: str(raw.webhookId),
|
|
102
79
|
label: str(raw.label) ?? id,
|
|
103
80
|
secret: str(raw.secret),
|
|
104
|
-
session: str(raw.session),
|
|
105
81
|
createdAt: str(raw.createdAt) ?? '',
|
|
106
82
|
};
|
|
107
83
|
}
|
|
@@ -125,76 +101,21 @@ export function tokenMatches(secret: string, given: string): boolean {
|
|
|
125
101
|
return want.length === got.length && timingSafeEqual(want, got);
|
|
126
102
|
}
|
|
127
103
|
|
|
128
|
-
export function warnOnLegacyWebhooks(): void {
|
|
129
|
-
const legacy = readJson<{ endpoints?: unknown[] }>(LEGACY_WEBHOOKS_FILE, {});
|
|
130
|
-
const count = Array.isArray(legacy.endpoints) ? legacy.endpoints.length : 0;
|
|
131
|
-
if (count > 0)
|
|
132
|
-
log.warn(
|
|
133
|
-
{ file: LEGACY_WEBHOOKS_FILE, endpoints: count },
|
|
134
|
-
'webhook: webhooks.json is no longer read — endpoints live in the accounts table; these endpoints are inactive',
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export const loadTunnelConfig = (): TunnelConfig | null =>
|
|
139
|
-
readJson<TunnelConfig | null>(FILE, null, {
|
|
140
|
-
warn: 'tunnel.json: malformed, ignoring',
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
function fetchTunnelToken(name: string): string | null {
|
|
144
|
-
const r = spawnSync('cloudflared', ['tunnel', 'token', name], {
|
|
145
|
-
encoding: 'utf8',
|
|
146
|
-
});
|
|
147
|
-
if (r.status !== 0) return null;
|
|
148
|
-
const token = r.stdout.trim();
|
|
149
|
-
return token.length > 0 ? token : null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
interface Plan {
|
|
153
|
-
args: string[];
|
|
154
|
-
env: NodeJS.ProcessEnv;
|
|
155
|
-
mode: string;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
104
|
export class Tunnel {
|
|
159
105
|
private child: ChildProcess | null = null;
|
|
160
106
|
private closed = false;
|
|
161
|
-
private token: string | null | undefined = undefined;
|
|
162
107
|
|
|
163
108
|
constructor(
|
|
164
|
-
private cfg: TunnelConfig,
|
|
165
109
|
private port: number,
|
|
166
110
|
private onUrl: (url: string) => void = () => undefined,
|
|
167
111
|
private resolves: Resolves = resolvesAtCloudflare,
|
|
168
112
|
) {}
|
|
169
113
|
|
|
170
|
-
get hostname(): string {
|
|
171
|
-
if (!isQuick(this.cfg)) return this.cfg.hostname;
|
|
172
|
-
return liveUrl === null ? '' : new URL(liveUrl).host;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
114
|
private origin(): string {
|
|
176
115
|
return `http://127.0.0.1:${String(this.port)}`;
|
|
177
116
|
}
|
|
178
117
|
|
|
179
|
-
private plan(): Plan {
|
|
180
|
-
if (isQuick(this.cfg))
|
|
181
|
-
return {
|
|
182
|
-
args: ['--no-autoupdate', 'tunnel', '--url', this.origin()],
|
|
183
|
-
env: process.env,
|
|
184
|
-
mode: 'quick',
|
|
185
|
-
};
|
|
186
|
-
if (this.token === undefined) this.token = fetchTunnelToken(this.cfg.name);
|
|
187
|
-
const args = ['--no-autoupdate', 'tunnel', 'run', '--url', this.origin()];
|
|
188
|
-
if (!this.token) args.push(this.cfg.name);
|
|
189
|
-
return {
|
|
190
|
-
args,
|
|
191
|
-
env: this.token ? { ...process.env, TUNNEL_TOKEN: this.token } : process.env,
|
|
192
|
-
mode: this.token ? 'token' : 'named',
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
118
|
private noticeUrl(text: string): void {
|
|
197
|
-
if (!isQuick(this.cfg)) return;
|
|
198
119
|
const url = quickTunnelUrlIn(text);
|
|
199
120
|
if (url === null || url === liveUrl) return;
|
|
200
121
|
liveUrl = url;
|
|
@@ -213,15 +134,12 @@ export class Tunnel {
|
|
|
213
134
|
|
|
214
135
|
start(): void {
|
|
215
136
|
if (this.closed) return;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'
|
|
137
|
+
log.info({ port: this.port }, 'cloudflared quick tunnel starting');
|
|
138
|
+
const child = spawn(
|
|
139
|
+
'cloudflared',
|
|
140
|
+
['--no-autoupdate', 'tunnel', '--url', this.origin()],
|
|
141
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
220
142
|
);
|
|
221
|
-
const child = spawn('cloudflared', plan.args, {
|
|
222
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
223
|
-
env: plan.env,
|
|
224
|
-
});
|
|
225
143
|
this.child = child;
|
|
226
144
|
child.stderr?.on('data', (d: Buffer | string) => {
|
|
227
145
|
const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
|
|
@@ -38,7 +38,7 @@ const VAGUE_MIMES = new Set([
|
|
|
38
38
|
|
|
39
39
|
const mib = (n: number): string => `${Math.round(n / (1024 * 1024))} MiB`;
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
const uploadLimit = (): string =>
|
|
42
42
|
`${mib(MAX_UPLOAD_BYTES)} (${MAX_UPLOAD_BYTES} bytes)`;
|
|
43
43
|
|
|
44
44
|
const tooLarge = (): string =>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const METRO_VERSION = process.env.npm_package_version ?? '0.1.0-beta.15';
|
|
@@ -56,7 +56,7 @@ export async function assertRenameFreeOfClash(
|
|
|
56
56
|
if (clash !== null) throw nameClash(name, clash);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
async function connectorIdsOfAgent(agentId: string): Promise<string[]> {
|
|
60
60
|
const rows = await getDb()
|
|
61
61
|
.select({ connectorId: agentConnectors.connectorId })
|
|
62
62
|
.from(agentConnectors)
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
VerifiedServer,
|
|
6
6
|
} from '../daemon/connector-verify.js';
|
|
7
7
|
import { readStoredTools } from '../daemon/connector-tools.js';
|
|
8
|
+
import { isRecord } from '../daemon/is-record.js';
|
|
8
9
|
|
|
9
10
|
const CONNECTOR_NAME_MAX = 64;
|
|
10
11
|
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
|
@@ -22,10 +23,6 @@ export interface ConnectorConfig {
|
|
|
22
23
|
oauth: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
26
|
function text(value: unknown): string {
|
|
30
27
|
return typeof value === 'string' ? value : '';
|
|
31
28
|
}
|
|
@@ -25,7 +25,7 @@ export function staleUsable(auth: OAuthAuth, now = Date.now()): boolean {
|
|
|
25
25
|
return auth.expiresAt === undefined || auth.expiresAt > now;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
function headerAuthHeaders(
|
|
29
29
|
auth: ConnectorAuth,
|
|
30
30
|
): Record<string, string> | null {
|
|
31
31
|
return auth.kind === 'header' ? { [auth.name]: auth.value } : null;
|
|
@@ -278,10 +278,12 @@ export function localSetAgentConnectors(
|
|
|
278
278
|
save(stored);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
export function
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.
|
|
281
|
+
export function localHoldEverywhere(connectorId: string, dir = agentsDir()): void {
|
|
282
|
+
for (const stored of storedAgents(dir)) {
|
|
283
|
+
if (stored.file.connectors.includes(connectorId)) continue;
|
|
284
|
+
stored.file.connectors = [...stored.file.connectors, connectorId];
|
|
285
|
+
save(stored);
|
|
286
|
+
}
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
export function localDropConnectorEverywhere(connectorId: string, dir = agentsDir()): void {
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { AGENT_NAME_RE, ID_RE } from './ids.js';
|
|
5
5
|
import { STATIONS, type StationName } from './schema.js';
|
|
6
6
|
import type { LoadedAccount, LoadedAgent, StationSource } from './materialize.js';
|
|
7
|
+
import { isRecord } from '../daemon/is-record.js';
|
|
7
8
|
|
|
8
9
|
export const AGENT_FILE = 'agent.json';
|
|
9
10
|
|
|
@@ -30,9 +31,6 @@ export function agentsDir(): string {
|
|
|
30
31
|
: join(homedir(), '.metro', 'agents');
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
34
|
-
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
35
|
-
|
|
36
34
|
function fail(path: string, reason: string): never {
|
|
37
35
|
throw new AgentFileError(`${path}: ${reason}`);
|
|
38
36
|
}
|
|
@@ -41,8 +41,8 @@ import {
|
|
|
41
41
|
assertLocalOwner,
|
|
42
42
|
connectorIdsOfLocalAgent,
|
|
43
43
|
localAgentConnectorIds,
|
|
44
|
-
localAgentsWith,
|
|
45
44
|
localDropConnectorEverywhere,
|
|
45
|
+
localHoldEverywhere,
|
|
46
46
|
localOwnedAgentOrThrow,
|
|
47
47
|
localSetAgentConnectors,
|
|
48
48
|
LOCAL_PROJECT_ID,
|
|
@@ -70,7 +70,7 @@ const isRow = (v: unknown): v is { id: string; name: string; url: string; config
|
|
|
70
70
|
const asList = (value: unknown): unknown[] =>
|
|
71
71
|
Array.isArray(value) ? value.map((item: unknown) => item) : [];
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
|
|
74
74
|
const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
|
|
75
75
|
warn: 'connectors.json: malformed, ignoring',
|
|
76
76
|
});
|
|
@@ -125,9 +125,16 @@ export async function localGetConnector(subject: string, id: string, dir = agent
|
|
|
125
125
|
return Promise.resolve(connectorFromRow(rowOrThrow(subject, id, dir)));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function assertNameFree(name: string, exceptId: string | null, dir: string): void {
|
|
129
|
+
if (readLocalConnectors(dir).some((r) => r.id !== exceptId && r.name === name))
|
|
130
|
+
throw new ConnectorError(`a connector named '${name}' already exists on this daemon`, 409);
|
|
131
|
+
}
|
|
132
|
+
|
|
128
133
|
function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
|
|
134
|
+
assertNameFree(name, null, dir);
|
|
129
135
|
const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), transport: 'http', config };
|
|
130
136
|
writeRows(dir, [...readLocalConnectors(dir), row]);
|
|
137
|
+
localHoldEverywhere(row.id, dir);
|
|
131
138
|
return connectorFromRow(row);
|
|
132
139
|
}
|
|
133
140
|
|
|
@@ -201,24 +208,10 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
|
|
|
201
208
|
}
|
|
202
209
|
}
|
|
203
210
|
|
|
204
|
-
const nameClash = (name: string, agent: string): ConnectorError =>
|
|
205
|
-
new ConnectorError(`the agent '${agent}' already has a connector named '${name}'`, 409);
|
|
206
|
-
|
|
207
|
-
function clashFor(name: string, exceptId: string, agentIds: string[], dir: string): string | null {
|
|
208
|
-
const rows = readLocalConnectors(dir);
|
|
209
|
-
for (const agentId of agentIds) {
|
|
210
|
-
const held = connectorIdsOfLocalAgent(agentId, dir) ?? [];
|
|
211
|
-
const other = rows.find((r) => r.id !== exceptId && held.includes(r.id) && r.name === name);
|
|
212
|
-
if (other !== undefined) return localAgentsWith(other.id, dir).find((a) => a.id === agentId)?.name ?? agentId;
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
211
|
export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
218
212
|
const name = connectorName(raw);
|
|
219
213
|
const row = rowOrThrow(subject, id, dir);
|
|
220
|
-
|
|
221
|
-
if (clash !== null) throw nameClash(name, clash);
|
|
214
|
+
assertNameFree(name, id, dir);
|
|
222
215
|
return Promise.resolve(replace(dir, { ...row, name }));
|
|
223
216
|
}
|
|
224
217
|
|
|
@@ -252,11 +245,7 @@ export async function localAddConnector(subject: string, agentId: string, connec
|
|
|
252
245
|
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
253
246
|
if (row === undefined) throw missing();
|
|
254
247
|
const held = localAgentConnectorIds(subject, agentId, dir);
|
|
255
|
-
if (!held.includes(connectorId))
|
|
256
|
-
const clash = clashFor(row.name, connectorId, [agentId], dir);
|
|
257
|
-
if (clash !== null) throw nameClash(row.name, clash);
|
|
258
|
-
localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
|
|
259
|
-
}
|
|
248
|
+
if (!held.includes(connectorId)) localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
|
|
260
249
|
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
261
250
|
}
|
|
262
251
|
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
type AllowlistMap,
|
|
22
22
|
} from './agent-map.js';
|
|
23
23
|
import { setKeyMap } from './key-map.js';
|
|
24
|
-
import { carryTokenStores, PREVIOUS_ACCOUNT_ID } from './token-carry.js';
|
|
25
24
|
import { STATIONS, stations, agents, type StationName } from './schema.js';
|
|
26
25
|
|
|
27
26
|
interface StationTarget {
|
|
@@ -149,7 +148,7 @@ export async function loadAgentForRuntime(
|
|
|
149
148
|
};
|
|
150
149
|
}
|
|
151
150
|
|
|
152
|
-
|
|
151
|
+
const pgSource: StationSource = () => loadAgents();
|
|
153
152
|
|
|
154
153
|
async function loadAgents(): Promise<LoadedAgent[]> {
|
|
155
154
|
const db = getDb();
|
|
@@ -182,14 +181,6 @@ async function loadAgents(): Promise<LoadedAgent[]> {
|
|
|
182
181
|
return out;
|
|
183
182
|
}
|
|
184
183
|
|
|
185
|
-
function trainConfig(
|
|
186
|
-
config: Record<string, unknown>,
|
|
187
|
-
): Record<string, unknown> {
|
|
188
|
-
return Object.fromEntries(
|
|
189
|
-
Object.entries(config).filter(([key]) => key !== PREVIOUS_ACCOUNT_ID),
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
184
|
function trainStubPath(station: StationName): string {
|
|
194
185
|
return join(trainsDir(), `${station}.ts`);
|
|
195
186
|
}
|
|
@@ -208,7 +199,7 @@ export function writeIfChanged(path: string, content: string): boolean {
|
|
|
208
199
|
return true;
|
|
209
200
|
}
|
|
210
201
|
|
|
211
|
-
|
|
202
|
+
const isLocalRuntime = (): boolean =>
|
|
212
203
|
(process.env.METRO_RUN_TOKEN?.trim() ?? '') !== '';
|
|
213
204
|
|
|
214
205
|
export const stationRunsHere = (station: StationName): boolean =>
|
|
@@ -248,12 +239,11 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
|
248
239
|
}
|
|
249
240
|
setAgentMap(map, names);
|
|
250
241
|
setAllowlistMap(allow);
|
|
251
|
-
carryTokenStores(list.flatMap((agent) => agent.accounts));
|
|
252
242
|
|
|
253
243
|
const active = new Map<StationName, number>();
|
|
254
244
|
const changed: StationName[] = [];
|
|
255
245
|
for (const [station, accts] of byStation) {
|
|
256
|
-
const records = accts.map((a) => ({ id: a.id, ...
|
|
246
|
+
const records = accts.map((a) => ({ id: a.id, ...a.config }));
|
|
257
247
|
const path = accountFilePath(station);
|
|
258
248
|
const content = JSON.stringify(records, null, 2);
|
|
259
249
|
if (currentText(path) !== content) {
|
|
@@ -33,7 +33,7 @@ const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
|
33
33
|
const missing = (): ProjectError => new ProjectError('no such project', 404);
|
|
34
34
|
const forbidden = (why: string): ProjectError => new ProjectError(why, 403);
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
function projectName(raw: unknown): string {
|
|
37
37
|
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
38
38
|
if (name === '' || name.length > NAME_MAX)
|
|
39
39
|
throw new ProjectError(
|
|
@@ -54,7 +54,7 @@ export interface Access {
|
|
|
54
54
|
role: ProjectRole;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
async function memberAccessOrThrow(
|
|
58
58
|
subject: string,
|
|
59
59
|
projectId: string,
|
|
60
60
|
): Promise<Access> {
|
|
@@ -101,7 +101,7 @@ async function addMemberRow(
|
|
|
101
101
|
.onConflictDoNothing();
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
async function ensureDefaultProject(userId: string): Promise<string> {
|
|
105
105
|
const existing = await getDb()
|
|
106
106
|
.select({ id: projects.id })
|
|
107
107
|
.from(projects)
|
|
@@ -91,7 +91,7 @@ export async function runtimeLabels(
|
|
|
91
91
|
return out;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
async function runtimeFor(agentId: string): Promise<RuntimeView | null> {
|
|
95
95
|
const rows = await getDb()
|
|
96
96
|
.select({
|
|
97
97
|
id: runtimes.id,
|